mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
feat(slack): share task composer and isolate internal notes
This commit is contained in:
1 parent
fbcae3cd88
commit
a03d8b394e
28 files changed
+1382
-47
No files matched your search
@@ -331,6 +331,27 @@ chat handoff. An agent's external file selection must match the attachment's
|
||||
company, task, agent, and originating run. Editing or recreating a work-product
|
||||
record cannot reassign that authority to a later run.
|
||||
|
||||
## Human-only task Internal notes
|
||||
|
||||
`issue_internal_notes` stores notes separately from `issue_comments`. Its
|
||||
composite company/task foreign key prevents cross-company task attachment;
|
||||
`(issue_id, author_user_id, client_request_id)` makes a human save retry-safe.
|
||||
The company/task/timestamp/id index supports bounded, stable pagination without
|
||||
rounding database timestamp precision through JavaScript dates. Task deletion
|
||||
cascades notes. No historical ordinary comments are backfilled or reclassified.
|
||||
|
||||
The dedicated API requires a signed-in human browser session and current active
|
||||
company membership; viewers can read but not write. Agent credentials, API keys
|
||||
and local-implicit actors are denied. Notes are deliberately excluded from task
|
||||
comments, search, references, agent context, activity bodies, wakes and external
|
||||
chat publications. A transactional, content-free activity row records creation
|
||||
once without an activity-stream/plugin publication. HTTP failure logging must
|
||||
not serialize note bodies. There is no edit/delete API in this initial slice.
|
||||
|
||||
Notes are ordinary protected database data and are included in logical backups.
|
||||
This is not a secrets vault or a host-level sandbox: a process with direct
|
||||
database/filesystem access is outside the API/context isolation guarantee.
|
||||
|
||||
## Question-response delivery receipts
|
||||
|
||||
`issue_question_response_deliveries` is the retry-safe, content-free outbox for
|
||||
|
||||
@@ -55,9 +55,11 @@ discovery only: no editing, delegation, new tasks, PR or merge.
|
||||
Expect one Connect Slack card in Slack and the canonical Paperclip task. The
|
||||
link hands off through the authenticated task to personal OAuth. After the user
|
||||
approves, the existing durable intent delivery resumes the original request.
|
||||
The answer keeps its original audience. A normal private Paperclip turn stays
|
||||
private; Reply via Slack opts in to that turn's Slack reply. Not now in Slack
|
||||
opens the board card for decline.
|
||||
The answer keeps its original audience. On legacy v1 tasks, the normal Paperclip
|
||||
composer stays internal and Reply via Slack opts in to that turn's Slack reply.
|
||||
On threaded v2 tasks, the normal composer shares request and answer by default;
|
||||
use the separate human-only Internal notes editor for non-agent notes. Not now
|
||||
in Slack opens the board card for decline.
|
||||
|
||||
`ensure_capability` supports `slack.read_channel` and `slack.read_thread` only.
|
||||
Its public states are CONNECTED, AUTH_REQUIRED and UNAVAILABLE. Search is
|
||||
@@ -145,10 +147,14 @@ after activation get one task/thread, a deduplicated eyes receipt and editable
|
||||
working status. Both native and legacy ACPX runs can supply safe coarse progress;
|
||||
tool updates are coalesced, and short runs may only show working then the answer.
|
||||
|
||||
For new v2 bindings, **Reply via Slack** shares request and answer. The normal
|
||||
Paperclip composer is still internal, disclosed in the banner. Default sharing,
|
||||
human-only notes, explicit child-task linkage and historical screenshot research
|
||||
remain unfinished. This is not full M2b acceptance. Focused verification includes
|
||||
For new v2 bindings, the normal Paperclip composer now shares request and answer,
|
||||
with an explicit destination disclosure. Shared requests are text-only and use
|
||||
the existing durable outbox; the CEO starts only after Slack confirms delivery.
|
||||
Separate **Internal notes** are human-only, stored outside task comments and
|
||||
agent context. Earlier comments/drafts are not retroactively sent. The general
|
||||
Tasks list now includes Idle conversations without changing dashboard attention
|
||||
or execution behavior. Explicit child-task linkage and historical screenshot
|
||||
research remain unfinished. This is not full M2b acceptance. Earlier focused verification includes
|
||||
291 tests in 11 suites and 34 selected integration checks, plus server/UI/adapter
|
||||
typechecks and the UI build. The bot upgrade has now completed: live status shows
|
||||
active v2, reactions enabled and cutoff `1790145683.516000`. Real threaded Slack
|
||||
@@ -159,9 +165,15 @@ receipt, one threaded working message, and an in-place final answer. Eyes are
|
||||
removed on completion. Subsequent live checks verified same-thread continuity on
|
||||
TES-4, a separate TES-5 root, labelled Paperclip request plus answer through Reply
|
||||
via Slack, and “CEO is using tools…” followed by a final answer in the same thread.
|
||||
The normal composer remains internal in this checkpoint. Next: Tasks-list
|
||||
visibility for Idle conversations, shared-default v2 composer and isolated
|
||||
human-only notes. No extra OAuth consent is needed for these UX fixes.
|
||||
At the subsequent September 23 checkpoint, migration 0285 was applied after a
|
||||
private backup. A live normal-composer request and its answer both reached the
|
||||
TES-4 thread, each in one publication attempt; the task returned to Idle.
|
||||
TES-4/TES-5 are visible in Tasks. A saved Internal-note canary did not enter
|
||||
comments, Slack publications, wakes or activity, including after that shared run.
|
||||
186 focused tests, server/UI typechecks, token gates, UI and Storybook builds
|
||||
passed. Full visual-baseline acceptance remains unrun. Notes are in database
|
||||
backups; their API/context isolation is not a sandbox against host/database
|
||||
administrators. No extra OAuth consent was needed for these UX fixes.
|
||||
|
||||
Implementation, coding-worker approval, artifacts, GitHub and merge are later
|
||||
milestones. Discovery guidance is not a security boundary for every CEO tool.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Slack CEO M2b: threaded, repository-backed research
|
||||
|
||||
Status: narrow threaded-DM preview deployed and activated; live roots, follow-up,
|
||||
explicit shared reply and coarse tool progress verified. Full
|
||||
shared normal composer, isolated human-only notes and coarse tool progress verified. Full
|
||||
M2b remains incomplete. Continue `codex/slack-ceo-poc1`,
|
||||
not the independent `codex/connection-capability-092226` checkout. Starting
|
||||
commit: `91339784ce9a740c149b34f013a1e29e7e7febdc`.
|
||||
@@ -32,12 +32,17 @@ runner now emits a payload-free tool activity signal, coalesced at 20 seconds.
|
||||
Short turns can go straight from working to the final answer. Never promise
|
||||
individual grep/command names or expose raw arguments, reasoning or paths.
|
||||
|
||||
For v2, explicit **Reply via Slack** shares the request before waking the CEO,
|
||||
then shares the answer. The banner explicitly calls this a preview: normal
|
||||
Paperclip composer messages still remain internal. Shared-default composer plus
|
||||
isolated human-only notes, explicit child-task announcement/binding, historical
|
||||
scan storage and screenshot inspection remain future M2b work. Their absence
|
||||
does not prevent the narrow new-DM test, but this is not full M2b acceptance.
|
||||
For v2, the normal Paperclip composer now uses the existing shared-request outbox:
|
||||
the labelled request reaches the exact Slack thread before the CEO wakes, and
|
||||
its answer returns there. The composer discloses sharing and is text-only; task
|
||||
file attachments are not yet supported. Its new, audience-specific draft key
|
||||
does not restore earlier internal drafts. The redundant v2 **Reply via Slack**
|
||||
box is removed; legacy TES-2 retains its original explicit-reply behavior.
|
||||
Separate **Internal notes** are available to signed-in company members, never
|
||||
through agent/API-key access or normal task context/search/publication. Existing
|
||||
ordinary comments are not migrated into this store and remain agent-readable;
|
||||
they are not retroactively posted to Slack. Explicit child-task binding,
|
||||
historical scan storage and screenshot inspection remain future M2b work.
|
||||
|
||||
Verification: 291 tests across 11 focused suites; 34 selected chat integration
|
||||
checks; server/UI/adapter-utils TypeScript checks; token gates and UI build pass.
|
||||
@@ -88,12 +93,53 @@ retry and created TES-5 after the admission correction.
|
||||
13 channel posts and 12 replies across three threads; this is reported coverage,
|
||||
not an independent audit of those upstream reads. Attachments were not examined.
|
||||
|
||||
Remaining immediate UX fixes: keep Idle Slack conversations discoverable in the
|
||||
Tasks list without making them active work; route the normal composer through the
|
||||
existing shared-request outbox for v2 tasks; add separately stored human-only
|
||||
Internal notes. Never retroactively publish existing internal comments. Historical
|
||||
channel reading, screenshots and explicit child-task thread linkage remain later
|
||||
work; the narrow preview does not claim those capabilities.
|
||||
These checks exposed three immediate UX fixes, completed in the following checkpoint.
|
||||
|
||||
### Shared composer, Internal notes and Idle visibility (September 23, 00:28 PDT)
|
||||
|
||||
Pushed the prior threaded work as `fbcae3cd88da9decbea089e2c37eccc57f4145b1` to
|
||||
`origin/codex/slack-ceo-poc1`, without a PR, before starting these fixes. The new
|
||||
UX changes are local and running on the isolated pilot. A private logical backup
|
||||
preceded generated migration `0285_abandoned_mentallo.sql` and server restart.
|
||||
|
||||
- The general Tasks list opts into Idle Slack conversations. Existing dashboard,
|
||||
review-attention and execution counts retain their prior default exclusions.
|
||||
Live Tasks inspection shows TES-2, TES-4 and TES-5 as Idle.
|
||||
- Normal v2 composer submission uses the durable board-reply path, including
|
||||
request IDs, confirmed-send-before-wake and authoritative audience checks.
|
||||
Ambiguous responses preserve the request identity for retry; they never fall
|
||||
back to an ordinary internal comment. File attachments/reassignment are rejected.
|
||||
- `issue_internal_notes` is a separate company/task-scoped table with idempotent
|
||||
human creation and paginated reads. Only an authenticated browser session plus
|
||||
current active membership can read it; viewers cannot write. Agents, board API
|
||||
keys and local-implicit actors cannot use the endpoint. Notes create no comment,
|
||||
task mutation, wake, activity-body copy, SSE payload or chat publication. One
|
||||
transactional content-free audit row records creation without a live event. HTTP
|
||||
logs/errors use content-free projections. This is an API/context boundary, not
|
||||
isolation from a host administrator or process with direct database access;
|
||||
database backups contain notes. Do not use this feature as a secrets vault.
|
||||
|
||||
Live normal-composer test on TES-4 at 00:26 PDT: run
|
||||
`4824bbc0-3c38-4bc7-83f0-48bbf2d518fd` succeeded and the task returned to Idle.
|
||||
Request publication `6a449038-d7a7-4e0c-8041-27845b264b0c` and final publication
|
||||
`211b11b1-5388-482e-9699-8d3f071082bd` each succeeded in one attempt. The real
|
||||
Slack UI showed the labelled request and “Shared composer test passed.” under
|
||||
the original TES-4 root. A harmless Internal-note canary saved beforehand remained
|
||||
absent from comments, publications, wakes and activity after that shared run.
|
||||
Saving it alone did not start any run.
|
||||
|
||||
Verification: 186 tests across eight focused suites passed, including real
|
||||
temporary-database note isolation, tenant boundaries, viewer/agent denial,
|
||||
idempotence, timestamp-precise pagination, ordinary composer retries and lifecycle
|
||||
regressions. Server/UI TypeScript, migration checks, token gates, UI build and
|
||||
Storybook build passed. Live UI inspection covered the notes dialog, shared
|
||||
composer and Tasks list. Full visual-baseline acceptance was not run; the broader
|
||||
baseline failures below remain. This is not a full-release or full-M2b claim.
|
||||
|
||||
Next: durable explicit new-task announcement/binding, then resumable historical
|
||||
reads and actual screenshot inspection, followed by pinned read-only repository
|
||||
assessment. TES-3 remains unlinked; do not infer authority from its prose source
|
||||
link. No implementation, delegation, PR or merge workflow is enabled here.
|
||||
|
||||
## Current evidence and immediate repair
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "issue_internal_notes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"author_user_id" text NOT NULL,
|
||||
"client_request_id" uuid NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "issue_internal_notes_request_uq" UNIQUE("issue_id","author_user_id","client_request_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "issue_internal_notes" ADD CONSTRAINT "issue_internal_notes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_internal_notes" ADD CONSTRAINT "issue_internal_notes_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "issue_internal_notes_issue_idx" ON "issue_internal_notes" USING btree ("company_id","issue_id","created_at","id");
|
||||
+814
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "861a2815-1418-4c71-a77b-d0e2e9566413",
|
||||
"prevId": "4770ad6d-2cdc-45c4-af89-c300fd134d91",
|
||||
"id": "e1655800-f133-4bf3-b810-a100641abcac",
|
||||
"prevId": "8a038eb1-fe99-4de5-9608-3f4c7c2b53e5",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -5675,6 +5675,25 @@
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"binding_mode": {
|
||||
"name": "binding_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'legacy'"
|
||||
},
|
||||
"origin_principal_id": {
|
||||
"name": "origin_principal_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"origin_user_id": {
|
||||
"name": "origin_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_generation": {
|
||||
"name": "session_generation",
|
||||
"type": "integer",
|
||||
@@ -5701,6 +5720,12 @@
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"communication_guidance": {
|
||||
"name": "communication_guidance",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
@@ -5825,6 +5850,21 @@
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"chat_conversations_origin_principal_fk": {
|
||||
"name": "chat_conversations_origin_principal_fk",
|
||||
"tableFrom": "chat_conversations",
|
||||
"tableTo": "chat_external_principals",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"origin_principal_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"chat_conversations_company_issue_fk": {
|
||||
"name": "chat_conversations_company_issue_fk",
|
||||
"tableFrom": "chat_conversations",
|
||||
@@ -5887,6 +5927,14 @@
|
||||
"chat_conversations_state_check": {
|
||||
"name": "chat_conversations_state_check",
|
||||
"value": "\"chat_conversations\".\"state\" in ('active', 'waiting', 'completed', 'unavailable', 'endpoint_removed')"
|
||||
},
|
||||
"chat_conversations_binding_mode_check": {
|
||||
"name": "chat_conversations_binding_mode_check",
|
||||
"value": "\"chat_conversations\".\"binding_mode\" in ('legacy', 'slack_dm_thread_v2')"
|
||||
},
|
||||
"chat_conversations_slack_thread_binding_check": {
|
||||
"name": "chat_conversations_slack_thread_binding_check",
|
||||
"value": "\"chat_conversations\".\"binding_mode\" = 'legacy' or (\n \"chat_conversations\".\"is_direct_message\" and \"chat_conversations\".\"session_generation\" = 1\n and \"chat_conversations\".\"origin_principal_id\" is not null and \"chat_conversations\".\"origin_user_id\" is not null\n and \"chat_conversations\".\"external_conversation_id\" ~ '^D[A-Z0-9]+$'\n and \"chat_conversations\".\"external_thread_id\" ~ '^slack:D[A-Z0-9]+:[0-9]{1,12}[.][0-9]{1,6}$'\n and split_part(\"chat_conversations\".\"external_thread_id\", ':', 2) = \"chat_conversations\".\"external_conversation_id\")"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
@@ -6611,6 +6659,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"communication_instructions": {
|
||||
"name": "communication_instructions",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"allow_direct_messages": {
|
||||
"name": "allow_direct_messages",
|
||||
"type": "boolean",
|
||||
@@ -8096,6 +8151,519 @@
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_github_configurations": {
|
||||
"name": "chat_github_configurations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"endpoint_id": {
|
||||
"name": "endpoint_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revision": {
|
||||
"name": "revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"configuration": {
|
||||
"name": "configuration",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_by_user_id": {
|
||||
"name": "updated_by_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
|
||||
"name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
|
||||
"tableFrom": "chat_github_configurations",
|
||||
"tableTo": "chat_endpoints",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"endpoint_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"chat_github_config_revision_check": {
|
||||
"name": "chat_github_config_revision_check",
|
||||
"value": "\"chat_github_configurations\".\"revision\" > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_github_registrations": {
|
||||
"name": "chat_github_registrations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint_id": {
|
||||
"name": "endpoint_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state_hash": {
|
||||
"name": "state_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"trusted_origin": {
|
||||
"name": "trusted_origin",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"chat_github_registration_state_uq": {
|
||||
"name": "chat_github_registration_state_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "state_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_github_registration_endpoint_idx": {
|
||||
"name": "chat_github_registration_endpoint_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "endpoint_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "expires_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
|
||||
"name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
|
||||
"tableFrom": "chat_github_registrations",
|
||||
"tableTo": "chat_endpoints",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"endpoint_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"chat_github_registration_status_check": {
|
||||
"name": "chat_github_registration_status_check",
|
||||
"value": "\"chat_github_registrations\".\"status\" in ('pending', 'exchanging', 'completed', 'failed')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_github_reviews": {
|
||||
"name": "chat_github_reviews",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"endpoint_id": {
|
||||
"name": "endpoint_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"issue_id": {
|
||||
"name": "issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"repository": {
|
||||
"name": "repository",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"pull_number": {
|
||||
"name": "pull_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"head_sha": {
|
||||
"name": "head_sha",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"delivery_id": {
|
||||
"name": "delivery_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"configuration_revision": {
|
||||
"name": "configuration_revision",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"policy_snapshot": {
|
||||
"name": "policy_snapshot",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"event": {
|
||||
"name": "event",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"assessment": {
|
||||
"name": "assessment",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"conclusion": {
|
||||
"name": "conclusion",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"check_id": {
|
||||
"name": "check_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"check_url": {
|
||||
"name": "check_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"summary_id": {
|
||||
"name": "summary_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"summary_url": {
|
||||
"name": "summary_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"publication_receipts": {
|
||||
"name": "publication_receipts",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"chat_github_reviews_delivery_uq": {
|
||||
"name": "chat_github_reviews_delivery_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "endpoint_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "delivery_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_github_reviews_pull_idx": {
|
||||
"name": "chat_github_reviews_pull_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "endpoint_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "repository_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "pull_number",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_github_reviews_task_idx": {
|
||||
"name": "chat_github_reviews_task_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "issue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "run_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"chat_github_reviews_run_id_heartbeat_runs_id_fk": {
|
||||
"name": "chat_github_reviews_run_id_heartbeat_runs_id_fk",
|
||||
"tableFrom": "chat_github_reviews",
|
||||
"tableTo": "heartbeat_runs",
|
||||
"columnsFrom": [
|
||||
"run_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
|
||||
"name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
|
||||
"tableFrom": "chat_github_reviews",
|
||||
"tableTo": "chat_endpoints",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"endpoint_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": {
|
||||
"name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk",
|
||||
"tableFrom": "chat_github_reviews",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"issue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "restrict",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"chat_github_reviews_company_id_uq": {
|
||||
"name": "chat_github_reviews_company_id_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"company_id",
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"chat_github_review_pull_number_check": {
|
||||
"name": "chat_github_review_pull_number_check",
|
||||
"value": "\"chat_github_reviews\".\"pull_number\" > 0"
|
||||
},
|
||||
"chat_github_review_state_check": {
|
||||
"name": "chat_github_review_state_check",
|
||||
"value": "\"chat_github_reviews\".\"state\" in ('queued', 'running', 'completed', 'incomplete', 'error', 'superseded', 'manual_required')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.chat_teams_file_transfers": {
|
||||
"name": "chat_teams_file_transfers",
|
||||
"schema": "",
|
||||
@@ -25752,6 +26320,136 @@
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.issue_internal_notes": {
|
||||
"name": "issue_internal_notes",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"issue_id": {
|
||||
"name": "issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"author_user_id": {
|
||||
"name": "author_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"client_request_id": {
|
||||
"name": "client_request_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"issue_internal_notes_issue_idx": {
|
||||
"name": "issue_internal_notes_issue_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "issue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"issue_internal_notes_company_id_companies_id_fk": {
|
||||
"name": "issue_internal_notes_company_id_companies_id_fk",
|
||||
"tableFrom": "issue_internal_notes",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"issue_internal_notes_issue_company_fk": {
|
||||
"name": "issue_internal_notes_issue_company_fk",
|
||||
"tableFrom": "issue_internal_notes",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"company_id",
|
||||
"issue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"company_id",
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"issue_internal_notes_request_uq": {
|
||||
"name": "issue_internal_notes_request_uq",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"issue_id",
|
||||
"author_user_id",
|
||||
"client_request_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.issue_labels": {
|
||||
"name": "issue_labels",
|
||||
"schema": "",
|
||||
@@ -37416,6 +38114,26 @@
|
||||
"notNull": true,
|
||||
"default": true
|
||||
},
|
||||
"setup_pending": {
|
||||
"name": "setup_pending",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"last_webhook_delivery": {
|
||||
"name": "last_webhook_delivery",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"cron_expression": {
|
||||
"name": "cron_expression",
|
||||
"type": "text",
|
||||
@@ -37677,6 +38395,100 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.routine_webhook_test_receipts": {
|
||||
"name": "routine_webhook_test_receipts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"trigger_id": {
|
||||
"name": "trigger_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"delivery_key_hash": {
|
||||
"name": "delivery_key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"received_at": {
|
||||
"name": "received_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"routine_webhook_test_receipts_delivery_uq": {
|
||||
"name": "routine_webhook_test_receipts_delivery_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "trigger_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "delivery_key_hash",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"routine_webhook_test_receipts_company_id_companies_id_fk": {
|
||||
"name": "routine_webhook_test_receipts_company_id_companies_id_fk",
|
||||
"tableFrom": "routine_webhook_test_receipts",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk": {
|
||||
"name": "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk",
|
||||
"tableFrom": "routine_webhook_test_receipts",
|
||||
"tableTo": "routine_triggers",
|
||||
"columnsFrom": [
|
||||
"trigger_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.routines": {
|
||||
"name": "routines",
|
||||
"schema": "",
|
||||
+7
@@ -1982,6 +1982,13 @@
|
||||
"when": 1790143464911,
|
||||
"tag": "0284_amusing_bloodaxe",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 285,
|
||||
"version": "7",
|
||||
"when": 1790147738042,
|
||||
"tag": "0285_abandoned_mentallo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { folders } from "./folders.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueInternalNotes } from "./issue_internal_notes.js";
|
||||
export { issueWatchdogs } from "./issue_watchdogs.js";
|
||||
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
|
||||
export { issueRecoveryActions } from "./issue_recovery_actions.js";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { foreignKey, index, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
|
||||
// Separate from issue_comments: never fed into agent context, search,
|
||||
// references, activity bodies, wakes or external chat publications.
|
||||
export const issueInternalNotes = pgTable("issue_internal_notes", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull(),
|
||||
authorUserId: text("author_user_id").notNull(),
|
||||
clientRequestId: uuid("client_request_id").notNull(),
|
||||
body: text("body").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({
|
||||
issueCompanyFk: foreignKey({ columns: [table.companyId, table.issueId], foreignColumns: [issues.companyId, issues.id], name: "issue_internal_notes_issue_company_fk" }).onDelete("cascade"),
|
||||
requestUq: unique("issue_internal_notes_request_uq").on(table.issueId, table.authorUserId, table.clientRequestId),
|
||||
issueIdx: index("issue_internal_notes_issue_idx").on(table.companyId, table.issueId, table.createdAt, table.id),
|
||||
}));
|
||||
@@ -13,6 +13,8 @@ import { createHttpLogger } from "../middleware/logger.js";
|
||||
|
||||
describe("HTTP logger redaction", () => {
|
||||
it.each([
|
||||
{ method: "POST", path: "/api/issues/private-url-canary/internal-notes" },
|
||||
{ method: "GET", path: "/api/issues/private-url-canary/internal-notes?before=private-url-canary" },
|
||||
{ method: "POST", path: "/api/routine-triggers/public/private-url-canary/fire" },
|
||||
{ method: "PUT", path: "/api/routine-triggers/public/private-url-canary/fire" },
|
||||
|
||||
@@ -98,7 +100,7 @@ describe("HTTP logger redaction", () => {
|
||||
const log = JSON.parse(output.trim());
|
||||
expect(log.req).toMatchObject({
|
||||
method,
|
||||
url: path.includes("routine-triggers") ? "/api/routine-triggers/public/:publicId/fire" : "/api/chat-webhooks/:publicId/:provider",
|
||||
url: path.includes("internal-notes") ? "/api/issues/:id/internal-notes" : path.includes("routine-triggers") ? "/api/routine-triggers/public/:publicId/fire" : "/api/chat-webhooks/:publicId/:provider",
|
||||
});
|
||||
expect(log.reqBody).toBe("[REDACTED]");
|
||||
expect(log.err.message).toBe("Chat webhook request failed");
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agentWakeupRequests, chatActions, chatPublications, companies, companyMemberships, createDb,
|
||||
issueComments, issueInternalNotes, issues } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { issueInternalNoteRoutes } from "../routes/issue-internal-notes.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { companySearchService } from "../services/company-search.js";
|
||||
import { companySearchQuerySchema } from "@paperclipai/shared";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
(support.supported ? describe : describe.skip)("human-only Internal notes", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-private-notes-"); db = createDb(database.connectionString); }, 90000);
|
||||
afterAll(async () => { await db?.$client.end({ timeout: 0 }); await database?.cleanup(); });
|
||||
async function fixture() {
|
||||
const companyId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Private notes", issuePrefix: companyId.slice(0, 8) });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Thread", status: "in_review" });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
||||
const actor = { type: "board", source: "session", sessionId: randomUUID(), userId, companyIds: [companyId] };
|
||||
const appFor = (override = {}) => {
|
||||
const app = express(); app.use(express.json());
|
||||
app.use((req, _res, next) => { (req as any).actor = { ...actor, ...override }; next(); });
|
||||
app.use("/api", issueInternalNoteRoutes(db)); app.use(errorHandler);
|
||||
return app;
|
||||
};
|
||||
return { companyId, issueId, userId, actor, appFor, path: `/api/issues/${issueId}/internal-notes` };
|
||||
}
|
||||
it("stores one idempotent note separately without comments, search, task mutation, work or publication", async () => {
|
||||
const f = await fixture(), app = f.appFor();
|
||||
const input = { body: "human-only-canary-91427", clientRequestId: randomUUID() };
|
||||
const before = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
const [a, b] = await Promise.all([request(app).post(f.path).send(input), request(app).post(f.path).send(input)]);
|
||||
expect(a.status).toBe(201); expect(b.body.id).toBe(a.body.id);
|
||||
expect((await request(app).get(f.path)).body.notes).toHaveLength(1);
|
||||
expect((await request(app).get(f.path)).headers["cache-control"]).toBe("no-store");
|
||||
expect(await db.select().from(issues).where(eq(issues.id, f.issueId))).toEqual(before);
|
||||
for (const table of [issueComments, chatPublications, chatActions, agentWakeupRequests]) {
|
||||
expect(await db.select().from(table).where(eq(table.companyId, f.companyId))).toHaveLength(0);
|
||||
}
|
||||
const audit = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId));
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(audit[0]).toMatchObject({ action: "issue_internal_note.created", entityType: "issue_internal_note",
|
||||
entityId: a.body.id, actorType: "user", actorId: f.userId, details: null });
|
||||
expect(JSON.stringify(audit)).not.toContain(input.body);
|
||||
expect(await issueService(db).listComments(f.issueId)).toHaveLength(0);
|
||||
expect((await companySearchService(db).search(f.companyId, companySearchQuerySchema.parse({ q: input.body }))).results).toHaveLength(0);
|
||||
expect((await request(app).post(f.path).send({ ...input, body: "different" })).status).toBe(409);
|
||||
});
|
||||
it.each([
|
||||
{ type: "agent", onBehalfOfUserId: "human" }, { type: "none" },
|
||||
{ source: "board_api_key" }, { source: "local_implicit" }, { sessionId: undefined },
|
||||
])("denies non-human-session access on both paths: %j", async override => {
|
||||
const f = await fixture(), app = f.appFor(override);
|
||||
expect((await request(app).get(f.path)).status).toBe(403);
|
||||
expect((await request(app).post(f.path).send({ body: "secret", clientRequestId: randomUUID() })).status).toBe(403);
|
||||
});
|
||||
it("uses current company membership, not cached actor membership or admin claims", async () => {
|
||||
const f = await fixture(), app = f.appFor({ isInstanceAdmin: true });
|
||||
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.companyId, f.companyId));
|
||||
expect((await request(app).get(f.path)).status).toBe(404);
|
||||
expect((await request(app).get(`/api/issues/${randomUUID()}/internal-notes`)).status).toBe(404);
|
||||
expect((await request(f.appFor({ userId: randomUUID() })).get(f.path)).status).toBe(404);
|
||||
});
|
||||
it("lets current viewers read but not write", async () => {
|
||||
const f = await fixture();
|
||||
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.companyId, f.companyId));
|
||||
expect((await request(f.appFor()).get(f.path)).status).toBe(200);
|
||||
expect((await request(f.appFor()).post(f.path).send({ body: "no", clientRequestId: randomUUID() })).status).toBe(403);
|
||||
});
|
||||
it("paginates without leaking another task's cursor, and database rejects mixed-company notes", async () => {
|
||||
const f = await fixture(), other = await fixture();
|
||||
await db.insert(issueInternalNotes).values(Array.from({ length: 51 }, (_, i) => ({ companyId: f.companyId, issueId: f.issueId, authorUserId: f.userId, body: String(i), clientRequestId: randomUUID() })));
|
||||
const first = await request(f.appFor()).get(f.path);
|
||||
expect(first.body.notes).toHaveLength(50);
|
||||
const last = await request(f.appFor()).get(f.path).query({ before: first.body.nextCursor });
|
||||
expect(last.body.notes).toHaveLength(1); expect(last.body.nextCursor).toBeNull();
|
||||
expect((await request(other.appFor()).get(other.path).query({ before: first.body.nextCursor })).status).toBe(404);
|
||||
await expect(db.insert(issueInternalNotes).values({ companyId: other.companyId, issueId: f.issueId, authorUserId: f.userId, body: "no", clientRequestId: randomUUID() })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,10 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await db.select().from(issues).where(and(eq(issues.id, f.issueId), executionIssueCondition()))).toHaveLength(0);
|
||||
expect((await issueService(db).getById(f.issueId))?.externalConversationState).toBe("waiting");
|
||||
expect(await issueService(db).list(f.companyId)).toHaveLength(0);
|
||||
expect(await issueService(db).list(f.companyId, { includeIdleSlackConversations: true }))
|
||||
.toEqual([expect.objectContaining({ id: f.issueId, externalConversationState: "waiting" })]);
|
||||
expect(await issueService(db).count(f.companyId, { includeIdleSlackConversations: true })).toBe(1);
|
||||
expect(await issueService(db).count(f.companyId)).toBe(0);
|
||||
expect((await dashboardService(db).summary(f.companyId)).tasks.open).toBe(0);
|
||||
expect((await attentionService(db).list(f.companyId)).items.filter((item) => item.issueId === f.issueId)).toHaveLength(0);
|
||||
const results = await companySearchService(db).search(f.companyId, companySearchQuerySchema.parse({ q: "you there" }));
|
||||
@@ -157,7 +161,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
});
|
||||
app.use("/api", issueRoutes(db, {} as any));
|
||||
const response = await request(app).get(`/api/companies/${f.companyId}/issues`)
|
||||
.query({ view: "compact", q: "you there" });
|
||||
.query({ view: "compact", includeIdleSlackConversations: "true" });
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body).toEqual([expect.objectContaining({ id: f.issueId, status: "in_review", externalConversationState: "waiting" })]);
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ import { environmentService } from "./services/environments.js";
|
||||
import { environmentRuntimeService } from "./services/environment-runtime.js";
|
||||
import { projectRoutes } from "./routes/projects.js";
|
||||
import { issueRoutes } from "./routes/issues.js";
|
||||
import { issueInternalNoteRoutes } from "./routes/issue-internal-notes.js";
|
||||
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
||||
import { caseRoutes } from "./routes/cases.js";
|
||||
import { fileResourceRoutes } from "./routes/file-resources.js";
|
||||
@@ -833,6 +834,7 @@ export async function createApp(
|
||||
// Issue routes are intentionally mounted after the gateway is constructed because
|
||||
// issue approval endpoints delegate to it. The intervening routers use distinct
|
||||
// route prefixes, so this dependency does not change issue-route precedence.
|
||||
api.use(issueInternalNoteRoutes(db));
|
||||
api.use(issueRoutes(db, opts.storageService, {
|
||||
chatRunRetries: chatChannels,
|
||||
feedbackExportService: opts.feedbackExportService,
|
||||
|
||||
@@ -37,6 +37,10 @@ function normalizePath(url: string): string {
|
||||
return pathname.length > 0 ? pathname : "/";
|
||||
}
|
||||
|
||||
export function isInternalNoteHttpRequest(url: string | undefined): boolean {
|
||||
return Boolean(url && /\/api\/issues\/[^/]+\/internal-notes(?:\/|$)/i.test(normalizePath(url)));
|
||||
}
|
||||
|
||||
const SECRET_SENSITIVE_HTTP_PATHS = [
|
||||
/^\/api\/chat-endpoints\/[^/]+\/setup(?:-secret)?(?:\/|$)/,
|
||||
];
|
||||
@@ -77,6 +81,7 @@ export function isSecretSensitiveHttpRequest(
|
||||
url: string | undefined,
|
||||
): boolean {
|
||||
if (isPrivateWebhookHttpRequest(method, url)) return true;
|
||||
if (isInternalNoteHttpRequest(url)) return true;
|
||||
if (!method || !url) return false;
|
||||
if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false;
|
||||
const pathname = normalizePath(url);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { pinoHttp } from "pino-http";
|
||||
import { HTTP_LOG_REDACT_PATHS } from "./http-log-redaction.js";
|
||||
import {
|
||||
isPrivateWebhookHttpRequest,
|
||||
isInternalNoteHttpRequest,
|
||||
isSecretSensitiveHttpRequest,
|
||||
shouldSilenceHttpSuccessLog,
|
||||
} from "./http-log-policy.js";
|
||||
@@ -56,13 +57,14 @@ function isPrivateWebhook(req: {
|
||||
originalUrl?: unknown;
|
||||
url?: unknown;
|
||||
}) {
|
||||
return isPrivateWebhookHttpRequest(
|
||||
return isInternalNoteHttpRequest(requestClassificationUrl(req)) || isPrivateWebhookHttpRequest(
|
||||
req.method,
|
||||
requestClassificationUrl(req),
|
||||
);
|
||||
}
|
||||
|
||||
function privateWebhookLogUrl(url: unknown) {
|
||||
if (typeof url === "string" && isInternalNoteHttpRequest(url)) return "/api/issues/:id/internal-notes";
|
||||
return typeof url === "string" && /\/routine-triggers\/public(?:\/|$)/i.test(url)
|
||||
? "/api/routine-triggers/public/:publicId/fire"
|
||||
: "/api/chat-webhooks/:publicId/:provider";
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Router, type Request } from "express";
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { companyMemberships, issueInternalNotes, issues, type Db } from "@paperclipai/db";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { persistActivity } from "../services/activity-log.js";
|
||||
|
||||
const noteInput = z.object({ body: z.string().trim().min(1).max(8000), clientRequestId: z.string().uuid() }).strict();
|
||||
|
||||
/** No agent/API-key/local-implicit access, even when acting for a human. These
|
||||
* notes intentionally have no general comments/search/activity integration. */
|
||||
export function issueInternalNoteRoutes(db: Db) {
|
||||
const router = Router();
|
||||
async function authorize(req: Request, tx: Db, write = false) {
|
||||
if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId || !req.actor.sessionId) {
|
||||
throw forbidden("Sign in as a person to access Internal notes");
|
||||
}
|
||||
const id = z.string().uuid().parse(req.params.id);
|
||||
const [row] = await tx.select({ issue: issues, membership: companyMemberships }).from(issues)
|
||||
.innerJoin(companyMemberships, and(eq(companyMemberships.companyId, issues.companyId),
|
||||
eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, req.actor.userId),
|
||||
eq(companyMemberships.status, "active")))
|
||||
.where(eq(issues.id, id)).limit(1);
|
||||
if (!row) throw notFound("Task not found");
|
||||
if (write && row.membership.membershipRole === "viewer") throw forbidden("Viewer access is read-only");
|
||||
return { issue: row.issue, userId: req.actor.userId };
|
||||
}
|
||||
router.get("/issues/:id/internal-notes", async (req, res) => {
|
||||
const { issue } = await authorize(req, db);
|
||||
const conditions = [eq(issueInternalNotes.companyId, issue.companyId), eq(issueInternalNotes.issueId, issue.id)];
|
||||
if (req.query.before) {
|
||||
const id = z.string().uuid().parse(req.query.before);
|
||||
const [cursor] = await db.select().from(issueInternalNotes).where(and(...conditions, eq(issueInternalNotes.id, id)));
|
||||
if (!cursor) throw notFound("Note not found");
|
||||
// Keep PostgreSQL timestamp precision; round-tripping through Date loses
|
||||
// microseconds and can skip notes sharing a transaction timestamp.
|
||||
conditions.push(sql`(${issueInternalNotes.createdAt}, ${issueInternalNotes.id}) < (
|
||||
select n.created_at, n.id from issue_internal_notes n
|
||||
where n.id = ${cursor.id} and n.company_id = ${issue.companyId} and n.issue_id = ${issue.id})`);
|
||||
}
|
||||
const rows = await db.select().from(issueInternalNotes).where(and(...conditions))
|
||||
.orderBy(desc(issueInternalNotes.createdAt), desc(issueInternalNotes.id)).limit(51);
|
||||
res.set("Cache-Control", "no-store").json({ notes: rows.slice(0, 50), nextCursor: rows.length > 50 ? rows[49].id : null });
|
||||
});
|
||||
router.post("/issues/:id/internal-notes", validate(noteInput), async (req, res) => {
|
||||
const note = await db.transaction(async tx => {
|
||||
const { issue, userId } = await authorize(req, tx as unknown as Db, true);
|
||||
const [created] = await tx.insert(issueInternalNotes).values({ companyId: issue.companyId, issueId: issue.id,
|
||||
authorUserId: userId, body: req.body.body, clientRequestId: req.body.clientRequestId })
|
||||
.onConflictDoNothing().returning();
|
||||
const [saved] = created ? [created] : await tx.select().from(issueInternalNotes).where(and(
|
||||
eq(issueInternalNotes.issueId, issue.id), eq(issueInternalNotes.authorUserId, userId),
|
||||
eq(issueInternalNotes.clientRequestId, req.body.clientRequestId)));
|
||||
if (!saved || saved.body !== req.body.body) throw conflict("This request ID belongs to a different note");
|
||||
if (created) {
|
||||
// Audit the mutation once without copying the body or publishing an
|
||||
// activity event into the task stream, plugin bus or agent context.
|
||||
await persistActivity(tx as unknown as Db, {
|
||||
companyId: issue.companyId, actorType: "user", actorId: userId,
|
||||
action: "issue_internal_note.created", entityType: "issue_internal_note", entityId: created.id,
|
||||
});
|
||||
}
|
||||
// No task mutation, comment, audit-body copy, wake, SSE payload or outbox.
|
||||
return saved;
|
||||
});
|
||||
res.set("Cache-Control", "no-store").status(201).json(note);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -8102,6 +8102,7 @@ export function issueRoutes(
|
||||
includeRoutineExecutions:
|
||||
req.query.includeRoutineExecutions === "true" ||
|
||||
req.query.includeRoutineExecutions === "1",
|
||||
includeIdleSlackConversations: req.query.includeIdleSlackConversations === "true",
|
||||
excludeRoutineExecutions:
|
||||
req.query.excludeRoutineExecutions === "true" ||
|
||||
req.query.excludeRoutineExecutions === "1",
|
||||
|
||||
@@ -1767,6 +1767,8 @@ export interface IssueFilters {
|
||||
originKindPrefix?: string;
|
||||
originId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
/** Browse resting Slack conversations without treating them as active work. */
|
||||
includeIdleSlackConversations?: boolean;
|
||||
excludeRoutineExecutions?: boolean;
|
||||
includePluginOperations?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
@@ -7795,7 +7797,7 @@ export function issueService(db: Db) {
|
||||
];
|
||||
if (!filters?.q?.trim()) {
|
||||
conditions.push(isNull(issues.conversationAgentId));
|
||||
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
|
||||
if (!filters?.includeIdleSlackConversations && !filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
|
||||
conditions.push(nonIdleSlackIssueCondition());
|
||||
}
|
||||
}
|
||||
@@ -8129,7 +8131,7 @@ export function issueService(db: Db) {
|
||||
const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()];
|
||||
if (!filters?.q?.trim()) {
|
||||
conditions.push(isNull(issues.conversationAgentId));
|
||||
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
|
||||
if (!filters?.includeIdleSlackConversations && !filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
|
||||
conditions.push(nonIdleSlackIssueCondition());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { api } from "./client";
|
||||
|
||||
export type InternalNote = { id: string; companyId: string; issueId: string; authorUserId: string; body: string; createdAt: string };
|
||||
export const internalNotesApi = {
|
||||
list: (issueId: string, before?: string) => api.get<{ notes: InternalNote[]; nextCursor: string | null }>(
|
||||
`/issues/${issueId}/internal-notes${before ? `?before=${encodeURIComponent(before)}` : ""}`),
|
||||
create: (issueId: string, body: string, clientRequestId: string) => api.post<InternalNote>(
|
||||
`/issues/${issueId}/internal-notes`, { body, clientRequestId }),
|
||||
};
|
||||
@@ -98,6 +98,7 @@ export type IssueListFilters = {
|
||||
descendantOf?: string;
|
||||
createdFromIssueId?: string;
|
||||
includeRoutineExecutions?: boolean;
|
||||
includeIdleSlackConversations?: boolean;
|
||||
includeBlockedBy?: boolean;
|
||||
includeBlockedInboxAttention?: boolean;
|
||||
includeLiveDescendantSummary?: boolean;
|
||||
@@ -140,6 +141,8 @@ function issueListSearchParams(filters?: IssueListFilters) {
|
||||
if (filters?.createdFromIssueId) params.set("createdFromIssueId", filters.createdFromIssueId);
|
||||
if (filters?.includeRoutineExecutions)
|
||||
params.set("includeRoutineExecutions", "true");
|
||||
if (filters?.includeIdleSlackConversations)
|
||||
params.set("includeIdleSlackConversations", "true");
|
||||
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
|
||||
if (filters?.includeBlockedInboxAttention)
|
||||
params.set("includeBlockedInboxAttention", "true");
|
||||
|
||||
@@ -170,11 +170,13 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
|
||||
expect(container.textContent).toContain("Connection changed; reload before upgrading");
|
||||
});
|
||||
|
||||
it("labels the threaded preview's remaining composer limitation", async () => {
|
||||
it("discloses the shared default without republishing old comments", async () => {
|
||||
const binding = await mockChatEndpointsApi.getIssueBinding();
|
||||
mockChatEndpointsApi.getIssueBinding.mockResolvedValue({ ...binding, bindingMode: "slack_dm_thread_v2" });
|
||||
await renderBanner();
|
||||
expect(container.textContent).toContain("normal Paperclip composer does not send messages to Slack yet");
|
||||
expect(container.textContent).toContain("Messages sent from the composer and the CEO’s answers are shared");
|
||||
expect(container.textContent).toContain("Earlier Paperclip messages are not automatically copied");
|
||||
expect(container.textContent).not.toContain("Reply via Slack");
|
||||
expect(container.textContent).not.toContain("Enable threaded DMs");
|
||||
});
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ const filePhaseLabels: Record<ChatFileTransferPhase, string> = {
|
||||
};
|
||||
|
||||
export function useIssueChatBinding(companyId: string, issueId: string) {
|
||||
const { enabled } = useChatConnectorsEnabled();
|
||||
const { enabled, loaded } = useChatConnectorsEnabled();
|
||||
const queryEnabled = enabled && Boolean(companyId && issueId) && !issueId.startsWith("chat:");
|
||||
const query = useQuery({
|
||||
queryKey: ["issue-chat-binding", companyId, issueId],
|
||||
@@ -119,7 +119,8 @@ export function useIssueChatBinding(companyId: string, issueId: string) {
|
||||
});
|
||||
return {
|
||||
binding: queryEnabled ? (query.data ?? null) : null,
|
||||
isLoading: queryEnabled && query.isLoading,
|
||||
isLoading: !loaded || (queryEnabled && query.isLoading),
|
||||
isError: queryEnabled && query.isError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -536,8 +537,8 @@ function ConnectedTaskComposer({
|
||||
{binding.slackThreadUpgradeAvailable && <SlackThreadUpgradeNotice pending={upgradeThreads.isPending}
|
||||
error={upgradeThreads.isError ? (upgradeThreads.error instanceof Error ? upgradeThreads.error.message : "Could not start the upgrade.") : null}
|
||||
onUpgrade={() => upgradeThreads.mutate()} />}
|
||||
{binding.bindingMode === "slack_dm_thread_v2" && <p className="text-xs text-muted-foreground">Thread preview: use Reply via Slack to share a request and the CEO’s answer in this thread. The normal Paperclip composer does not send messages to Slack yet.</p>}
|
||||
{binding.slackReplyAvailable && <SlackReplyComposer sharedThread={binding.bindingMode === "slack_dm_thread_v2"} companyId={companyId} issueId={issueId} issueCacheRefs={issueCacheRefs}
|
||||
{binding.bindingMode === "slack_dm_thread_v2" && <p className="text-xs text-muted-foreground">Messages sent from the composer and the CEO’s answers are shared in this Slack thread. Internal notes stay with people in Paperclip. Earlier Paperclip messages are not automatically copied.</p>}
|
||||
{binding.slackReplyAvailable && binding.bindingMode !== "slack_dm_thread_v2" && <SlackReplyComposer companyId={companyId} issueId={issueId} issueCacheRefs={issueCacheRefs}
|
||||
endpointId={binding.endpointId} conversationId={binding.conversationId} />}
|
||||
{composing && (
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
@@ -842,7 +843,7 @@ function ConnectedTaskComposer({
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ordinary board comments remain Paperclip-only.
|
||||
{binding.bindingMode === "slack_dm_thread_v2" ? "This sends an update without asking the CEO to respond. Use the task composer for a conversation." : "Ordinary board comments remain Paperclip-only."}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TaskInternalNotes } from "./TaskInternalNotes";
|
||||
import { internalNotesApi } from "@/api/internalNotes";
|
||||
vi.mock("@/api/internalNotes", () => ({ internalNotesApi: { list: vi.fn(), create: vi.fn() } }));
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("isolated human-note editor", () => {
|
||||
let root: Root, element: HTMLDivElement, client: QueryClient;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); localStorage.clear();
|
||||
vi.spyOn(localStorage, "setItem");
|
||||
element = document.createElement("div"); document.body.append(element); root = createRoot(element);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
vi.mocked(internalNotesApi.list).mockResolvedValue({ notes: [], nextCursor: null });
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); client.clear(); element.remove(); vi.restoreAllMocks(); });
|
||||
const button = (text: string) => [...document.querySelectorAll("button")].find(b => b.textContent === text)!;
|
||||
async function render(issueId = "issue") {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><TaskInternalNotes key={issueId} issueId={issueId} companyId="company" userId="user" /></QueryClientProvider>));
|
||||
}
|
||||
async function enter(text: string) {
|
||||
await act(async () => {
|
||||
const input = document.querySelector("textarea")!;
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(input, text);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
it("uses a separate editor, saves with one retry ID, and never puts note text in localStorage", async () => {
|
||||
vi.mocked(internalNotesApi.create).mockRejectedValueOnce(new Error("lost receipt"));
|
||||
await render(); await act(async () => button("Internal notes").click());
|
||||
expect(document.body.textContent).toContain("Not sent to Slack or included in agent context");
|
||||
await enter("private-canary");
|
||||
await act(async () => button("Save Internal note").click());
|
||||
await vi.waitFor(() => expect(button("Retry same note")).toBeDefined());
|
||||
const id = vi.mocked(internalNotesApi.create).mock.calls[0][2];
|
||||
vi.mocked(internalNotesApi.create).mockResolvedValueOnce({ id: "note", companyId: "company", issueId: "issue", authorUserId: "user", body: "private-canary", createdAt: new Date().toISOString() });
|
||||
await act(async () => button("Retry same note").click());
|
||||
expect(internalNotesApi.create).toHaveBeenNthCalledWith(2, "issue", "private-canary", id);
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain("The CEO was not notified"));
|
||||
expect(localStorage.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not carry an unsaved note into another task", async () => {
|
||||
await render(); await act(async () => button("Internal notes").click()); await enter("private-canary");
|
||||
await render("other"); await act(async () => button("Internal notes").click());
|
||||
expect(document.querySelector("textarea")?.value).toBe("");
|
||||
expect(internalNotesApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { internalNotesApi } from "@/api/internalNotes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
/** Separate editor and query cache: private drafts never enter the task
|
||||
* composer, its localStorage, transcript, copy/export or agent history. */
|
||||
export function TaskInternalNotes({ issueId, companyId, userId }: { issueId: string; companyId: string; userId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [body, setBody] = useState("");
|
||||
const [pending, setPending] = useState<{ body: string; id: string } | null>(null);
|
||||
const client = useQueryClient();
|
||||
const key = ["human-internal-notes", companyId, userId, issueId];
|
||||
const notes = useInfiniteQuery({ queryKey: key, enabled: open, gcTime: 0,
|
||||
queryFn: ({ pageParam }) => internalNotesApi.list(issueId, pageParam ?? undefined),
|
||||
initialPageParam: null as string | null, getNextPageParam: page => page.nextCursor ?? undefined });
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const request = pending ?? { body: body.trim(), id: crypto.randomUUID() };
|
||||
setPending(request);
|
||||
const saved = await internalNotesApi.create(issueId, request.body, request.id);
|
||||
if (!saved?.id || saved.issueId !== issueId || saved.body !== request.body) throw new Error("Could not confirm the saved note. Retry this same note.");
|
||||
return saved;
|
||||
},
|
||||
onSuccess: () => { setBody(""); setPending(null); void client.invalidateQueries({ queryKey: key }); },
|
||||
});
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button size="sm" variant="ghost">Internal notes</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Internal notes</DialogTitle><DialogDescription>
|
||||
For signed-in people in this company only. Not sent to Slack or included in agent context. Text only; unsaved notes are lost if you leave this task.
|
||||
</DialogDescription></DialogHeader>
|
||||
<div className="max-h-64 overflow-y-auto space-y-3">
|
||||
{notes.isPending && <p className="text-sm text-muted-foreground">Loading notes…</p>}
|
||||
{notes.isError && <p role="alert" className="text-sm text-destructive">Could not load notes. <Button variant="link" onClick={() => void notes.refetch()}>Retry</Button></p>}
|
||||
{notes.data?.pages.flatMap(page => page.notes).map(note => <div key={note.id} className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{note.authorUserId === userId ? "You" : "Team member"} · {new Date(note.createdAt).toLocaleString()}</p>
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{note.body}</p>
|
||||
</div>)}
|
||||
{notes.data?.pages[0]?.notes.length === 0 && <p className="text-sm text-muted-foreground">No Internal notes yet.</p>}
|
||||
{notes.hasNextPage && <Button variant="ghost" disabled={notes.isFetchingNextPage} onClick={() => void notes.fetchNextPage()}>Older notes</Button>}
|
||||
</div>
|
||||
<label htmlFor="task-internal-note" className="text-sm">New Internal note</label>
|
||||
<Textarea id="task-internal-note" value={body} maxLength={8000} disabled={save.isPending || Boolean(pending)} onChange={event => { setBody(event.target.value); save.reset(); }} />
|
||||
{save.isError && <p role="alert" className="text-sm text-destructive">Could not confirm the save. Retry the same note; it will not create a duplicate.</p>}
|
||||
{save.isSuccess && <p role="status" className="text-sm text-muted-foreground">Note saved for people only. The CEO was not notified.</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>Close</Button>
|
||||
<Button disabled={!body.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Saving…" : pending ? "Retry same note" : "Save Internal note"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chatEndpointsApi, type ExternalChannelBindingSummary } from "@/api/chatEndpoints";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
|
||||
import { submitSharedThreadMessage } from "./shared-thread-submit";
|
||||
vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { requestSlackReply: vi.fn() } }));
|
||||
const binding = { provider: "slack", bindingMode: "slack_dm_thread_v2", slackReplyAvailable: true, endpointId: "endpoint", conversationId: "thread" } as ExternalChannelBindingSummary;
|
||||
describe("normal composer shared-thread submission", () => {
|
||||
it("uses the existing durable reply path and preserves the caller's retry identity", async () => {
|
||||
const receipt = { requestId: "action", commentId: "comment", status: "queued" } as const;
|
||||
vi.mocked(chatEndpointsApi.requestSlackReply).mockResolvedValue(receipt);
|
||||
expect(await submitSharedThreadMessage(binding, "hello", "same-id")).toEqual(receipt);
|
||||
expect(chatEndpointsApi.requestSlackReply).toHaveBeenLastCalledWith("endpoint", "thread", "hello", "same-id");
|
||||
});
|
||||
it.each([{}, { bindingMode: "legacy" }, { slackReplyAvailable: false }])("does not fall back to a private comment for an invalid binding", async changes => {
|
||||
const invalid = Object.keys(changes).length ? { ...binding, ...changes } : { ...binding, provider: "discord" };
|
||||
await expect(submitSharedThreadMessage(invalid as ExternalChannelBindingSummary, "hello", "id")).rejects.toThrow("unavailable");
|
||||
});
|
||||
it("refuses untransported attachments and absent retry identities", async () => {
|
||||
await expect(submitSharedThreadMessage(binding, "hello", "id", ["file"])).rejects.toThrow("text-only");
|
||||
await expect(submitSharedThreadMessage(binding, "hello")).rejects.toThrow("send identity");
|
||||
});
|
||||
it.each([new Error("Lost response"), { requestId: "incomplete" }])("keeps ambiguous receipts distinguishable for the composer retry state", async outcome => {
|
||||
if (outcome instanceof Error) vi.mocked(chatEndpointsApi.requestSlackReply).mockRejectedValueOnce(outcome);
|
||||
else vi.mocked(chatEndpointsApi.requestSlackReply).mockResolvedValueOnce(outcome as any);
|
||||
await expect(submitSharedThreadMessage(binding, "hello", "same-id")).rejects.toBeInstanceOf(CommentSubmissionUnknownError);
|
||||
});
|
||||
it("keeps definite authorization rejection distinct from lost delivery", async () => {
|
||||
const error = new ApiError("No access", 403, {});
|
||||
vi.mocked(chatEndpointsApi.requestSlackReply).mockRejectedValueOnce(error);
|
||||
await expect(submitSharedThreadMessage(binding, "hello", "id")).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiError } from "@/api/client";
|
||||
import { chatEndpointsApi, type ExternalChannelBindingSummary } from "@/api/chatEndpoints";
|
||||
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
|
||||
|
||||
export async function submitSharedThreadMessage(binding: ExternalChannelBindingSummary, body: string, clientRequestId?: string, attachmentIds?: string[]) {
|
||||
if (binding.provider !== "slack" || binding.bindingMode !== "slack_dm_thread_v2" || !binding.slackReplyAvailable) throw new Error("The Slack thread is unavailable. Check its connection before sending.");
|
||||
if (!clientRequestId) throw new Error("Reload this task to create a safe send identity.");
|
||||
if (attachmentIds?.length || /!?\[[^\]]*\]\([^)]*\/api\/[^)]*\)/.test(body)) throw new Error("Shared Slack requests are text-only for now. Remove task-file attachments before sending.");
|
||||
if (!body.trim() || body.length > 8000) throw new Error("Send between 1 and 8,000 characters.");
|
||||
try {
|
||||
const receipt = await chatEndpointsApi.requestSlackReply(binding.endpointId, binding.conversationId, body, clientRequestId);
|
||||
if (!receipt?.requestId || !receipt.commentId || !["queued", "submitted", "failed"].includes(receipt.status)) throw new CommentSubmissionUnknownError();
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status < 500) throw error;
|
||||
throw new CommentSubmissionUnknownError();
|
||||
}
|
||||
}
|
||||
@@ -209,7 +209,9 @@ import {
|
||||
hasVisibleMonitorSurface,
|
||||
} from "../components/IssueMonitorBanner";
|
||||
import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
|
||||
import { ExternallyConnectedTaskBanner } from "../components/chat/ExternallyConnectedTaskBanner";
|
||||
import { ExternallyConnectedTaskBanner, useIssueChatBinding } from "../components/chat/ExternallyConnectedTaskBanner";
|
||||
import { TaskInternalNotes } from "../components/chat/TaskInternalNotes";
|
||||
import { submitSharedThreadMessage } from "../components/chat/shared-thread-submit";
|
||||
import {
|
||||
IssueProperties,
|
||||
type IssuePropertiesDocumentDeepLink,
|
||||
@@ -1273,8 +1275,8 @@ type IssueDetailChatTabProps = {
|
||||
clientRequestId?: string,
|
||||
) => Promise<void>;
|
||||
onReviewConversation: () => Promise<void>;
|
||||
onImageUpload: (file: File) => Promise<string>;
|
||||
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||
onInterruptQueued: (runId: string | null) => Promise<void>;
|
||||
onDeleteComment?: (commentId: string) => Promise<void> | void;
|
||||
onPauseWorkRun?: (runId: string, feedback?: "composer") => Promise<void>;
|
||||
@@ -2988,6 +2990,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
enabled: !!issueId,
|
||||
});
|
||||
const issue = queriedIssue ?? conversation?.issue ?? draftIssue;
|
||||
const chatBinding = useIssueChatBinding(issue?.companyId ?? "", issue?.id ?? "");
|
||||
const sharedSlackThread = chatBinding.binding?.bindingMode === "slack_dm_thread_v2";
|
||||
const [sharedSendNotice, setSharedSendNotice] = useState<string | null>(null);
|
||||
useEffect(() => setSharedSendNotice(null), [issue?.id]);
|
||||
const resolveWritableIssueId = async () => {
|
||||
if (!conversation) return issueId!;
|
||||
const resolved = await conversation.ensureIssue();
|
||||
@@ -6195,6 +6201,23 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
attachmentIds?: string[],
|
||||
clientRequestId?: string,
|
||||
) => {
|
||||
if (issue?.originKind === "chat_channel" && (chatBinding.isLoading || chatBinding.isError)) {
|
||||
throw new Error("The message destination could not be confirmed. Refresh the task before sending.");
|
||||
}
|
||||
if (sharedSlackThread && chatBinding.binding) {
|
||||
try {
|
||||
if (reassignment) throw new Error("This Slack thread stays assigned to its CEO. Remove the reassignment before sending.");
|
||||
const receipt = await submitSharedThreadMessage(chatBinding.binding, body, clientRequestId, attachmentIds);
|
||||
setSharedSendNotice(receipt.status === "failed" ? "Request saved, but the CEO could not start. Check the connection before trying a new request." : "Request saved for Slack. The CEO starts after Slack confirms delivery.");
|
||||
invalidateIssueThreadLazily();
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueCollections();
|
||||
} catch (error) {
|
||||
setSharedSendNotice(error instanceof Error ? error.message : "The shared request could not be confirmed.");
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (reassignment) {
|
||||
await addCommentAndReassign.mutateAsync({
|
||||
body,
|
||||
@@ -6207,7 +6230,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
}
|
||||
await addComment.mutateAsync({ body, reopen, attachmentIds, clientRequestId });
|
||||
},
|
||||
[addComment, addCommentAndReassign],
|
||||
[addComment, addCommentAndReassign, issue?.originKind, chatBinding, sharedSlackThread, invalidateIssueThreadLazily, invalidateIssueDetail, invalidateIssueCollections],
|
||||
);
|
||||
const handleCommentImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
@@ -7801,7 +7824,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
onRefreshLatestComments={refetchLatestComments}
|
||||
composerRef={commentComposerRef}
|
||||
composerAccessory={
|
||||
hasVisibleMonitorSurface(issue) ? (
|
||||
sharedSlackThread ? <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">Shared with Slack · text only</p>
|
||||
{currentUserId && <TaskInternalNotes key={`${issue.id}:${currentUserId}`} issueId={issue.id} companyId={issue.companyId} userId={currentUserId} />}
|
||||
</div>
|
||||
{sharedSendNotice && <p role="status" className="text-xs text-muted-foreground">{sharedSendNotice}</p>}
|
||||
</div> : hasVisibleMonitorSurface(issue) ? (
|
||||
<IssueMonitorComposerStrip
|
||||
issue={issue}
|
||||
onCheckNow={() => checkIssueMonitorNow.mutate()}
|
||||
@@ -7824,8 +7853,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
draftKey={conversationAgent ? `paperclip:agent-chat-draft:${issue.companyId}:${currentUserId}:${conversationAgent.id}` : `paperclip:issue-comment-draft:${issue.id}`}
|
||||
reassignOptions={commentReassignOptions}
|
||||
draftKey={sharedSlackThread ? `paperclip:slack-shared-draft:v2:${issue.companyId}:${currentUserId}:${issue.id}:${chatBinding.binding!.conversationId}` : conversationAgent ? `paperclip:agent-chat-draft:${issue.companyId}:${currentUserId}:${conversationAgent.id}` : `paperclip:issue-comment-draft:${issue.id}`}
|
||||
reassignOptions={sharedSlackThread ? [] : commentReassignOptions}
|
||||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
@@ -7841,7 +7870,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
} : undefined,
|
||||
resumeHref: !activePauseHold.isRoot ? createIssueDetailPath(activePauseHoldRoot?.identifier ?? activePauseHold.rootIssueId) : undefined,
|
||||
} : null}
|
||||
composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
|
||||
composerDisabledReason={issue.originKind === "chat_channel" && (chatBinding.isLoading || chatBinding.isError) ? "Checking the message destination. Refresh if the connection cannot be loaded." : issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
|
||||
composerHint={composerHint}
|
||||
queuedCommentReason={queuedCommentReason}
|
||||
onVote={handleCommentVote}
|
||||
@@ -7855,8 +7884,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
),
|
||||
]);
|
||||
}}
|
||||
onImageUpload={handleCommentImageUpload}
|
||||
onAttachImage={handleCommentAttachImage}
|
||||
onImageUpload={sharedSlackThread ? undefined : handleCommentImageUpload}
|
||||
onAttachImage={sharedSlackThread ? undefined : handleCommentAttachImage}
|
||||
onInterruptQueued={handleInterruptQueuedRun}
|
||||
onDeleteComment={(commentId) =>
|
||||
deleteComment
|
||||
|
||||
@@ -158,6 +158,7 @@ export function Issues() {
|
||||
workspaceIdFilter ?? "__all__",
|
||||
"compact",
|
||||
"with-routine-executions",
|
||||
"with-idle-slack-conversations",
|
||||
"infinite",
|
||||
issuePageSize,
|
||||
],
|
||||
@@ -165,6 +166,7 @@ export function Issues() {
|
||||
participantAgentId,
|
||||
workspaceId: workspaceIdFilter,
|
||||
includeRoutineExecutions: true,
|
||||
includeIdleSlackConversations: true,
|
||||
limit: issuePageSize,
|
||||
offset: pageParam,
|
||||
sortField: "updated",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { userEvent, within } from "storybook/test";
|
||||
import { TaskInternalNotes } from "@/components/chat/TaskInternalNotes";
|
||||
|
||||
function NotesPreview() {
|
||||
const [client] = useState(() => {
|
||||
const result = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false, refetchOnWindowFocus: false } } });
|
||||
result.setQueryData(["human-internal-notes", "company", "person", "task"], {
|
||||
pages: [{ notes: [{ id: "note", issueId: "task", authorUserId: "person", body: "Review the proposal with the team before authorizing implementation.", createdAt: "2026-09-23T07:00:00Z" }], nextCursor: null }], pageParams: [null],
|
||||
});
|
||||
return result;
|
||||
});
|
||||
return <QueryClientProvider client={client}><div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">Shared with Slack · text only</p>
|
||||
<TaskInternalNotes issueId="task" companyId="company" userId="person" />
|
||||
</div></QueryClientProvider>;
|
||||
}
|
||||
export default { title: "Connections/Slack thread Internal notes", component: NotesPreview, parameters: { layout: "padded" } } satisfies Meta<typeof NotesPreview>;
|
||||
type Story = StoryObj<typeof NotesPreview>;
|
||||
export const Closed: Story = {};
|
||||
export const HumanOnlyNotes: Story = { play: async ({ canvasElement }) => {
|
||||
await userEvent.click(within(canvasElement).getByRole("button", { name: "Internal notes" }));
|
||||
} };
|
||||
Reference in new issue
Block a user