From a03d8b394e6e57df8280c33178b58a36df1c0c07 Mon Sep 17 00:00:00 2001 From: Scott Tong Date: Wed, 23 Sep 2026 07:21:27 -0700 Subject: [PATCH] feat(slack): share task composer and isolate internal notes --- doc/DATABASE.md | 21 + doc/connections/SLACK-CEO-M2.md | 32 +- .../2026-09-22-slack-ceo-threaded-research.md | 72 +- .../migrations/0285_abandoned_mentallo.sql | 14 + ...{0280_snapshot.json => 0285_snapshot.json} | 816 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/index.ts | 1 + .../db/src/schema/issue_internal_notes.ts | 19 + .../src/__tests__/http-log-redaction.test.ts | 4 +- .../__tests__/issue-internal-notes.test.ts | 86 ++ .../slack-conversation-lifecycle.test.ts | 6 +- server/src/app.ts | 2 + server/src/middleware/http-log-policy.ts | 5 + server/src/middleware/logger.ts | 4 +- server/src/routes/issue-internal-notes.ts | 70 ++ server/src/routes/issues.ts | 1 + server/src/services/issues.ts | 6 +- ui/src/api/internalNotes.ts | 9 + ui/src/api/issues.ts | 3 + .../ExternallyConnectedTaskBanner.test.tsx | 6 +- .../chat/ExternallyConnectedTaskBanner.tsx | 11 +- .../chat/TaskInternalNotes.test.tsx | 52 ++ ui/src/components/chat/TaskInternalNotes.tsx | 55 ++ .../chat/shared-thread-submit.test.ts | 33 + .../components/chat/shared-thread-submit.ts | 18 + ui/src/pages/IssueDetail.tsx | 49 +- ui/src/pages/Issues.tsx | 2 + .../stories/slack-thread-notes.stories.tsx | 25 + 28 files changed, 1382 insertions(+), 47 deletions(-) create mode 100644 packages/db/src/migrations/0285_abandoned_mentallo.sql rename packages/db/src/migrations/meta/{0280_snapshot.json => 0285_snapshot.json} (98%) create mode 100644 packages/db/src/schema/issue_internal_notes.ts create mode 100644 server/src/__tests__/issue-internal-notes.test.ts create mode 100644 server/src/routes/issue-internal-notes.ts create mode 100644 ui/src/api/internalNotes.ts create mode 100644 ui/src/components/chat/TaskInternalNotes.test.tsx create mode 100644 ui/src/components/chat/TaskInternalNotes.tsx create mode 100644 ui/src/components/chat/shared-thread-submit.test.ts create mode 100644 ui/src/components/chat/shared-thread-submit.ts create mode 100644 ui/storybook/stories/slack-thread-notes.stories.tsx diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 1d21aa87f81..95a5df96245 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -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 diff --git a/doc/connections/SLACK-CEO-M2.md b/doc/connections/SLACK-CEO-M2.md index 0696322a597..c6e00b6e4e6 100644 --- a/doc/connections/SLACK-CEO-M2.md +++ b/doc/connections/SLACK-CEO-M2.md @@ -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. diff --git a/doc/plans/2026-09-22-slack-ceo-threaded-research.md b/doc/plans/2026-09-22-slack-ceo-threaded-research.md index cb3634b365e..bf53952f7ce 100644 --- a/doc/plans/2026-09-22-slack-ceo-threaded-research.md +++ b/doc/plans/2026-09-22-slack-ceo-threaded-research.md @@ -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 diff --git a/packages/db/src/migrations/0285_abandoned_mentallo.sql b/packages/db/src/migrations/0285_abandoned_mentallo.sql new file mode 100644 index 00000000000..f24c9011e65 --- /dev/null +++ b/packages/db/src/migrations/0285_abandoned_mentallo.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0280_snapshot.json b/packages/db/src/migrations/meta/0285_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0280_snapshot.json rename to packages/db/src/migrations/meta/0285_snapshot.json index ea3ae5e50dd..6c9aa9ce37f 100644 --- a/packages/db/src/migrations/meta/0280_snapshot.json +++ b/packages/db/src/migrations/meta/0285_snapshot.json @@ -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": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 9a7076a356c..cfc142f1a7c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1982,6 +1982,13 @@ "when": 1790143464911, "tag": "0284_amusing_bloodaxe", "breakpoints": true + }, + { + "idx": 285, + "version": "7", + "when": 1790147738042, + "tag": "0285_abandoned_mentallo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 81ea3bc33bb..ae9ec9e2eb5 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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"; diff --git a/packages/db/src/schema/issue_internal_notes.ts b/packages/db/src/schema/issue_internal_notes.ts new file mode 100644 index 00000000000..464861342a1 --- /dev/null +++ b/packages/db/src/schema/issue_internal_notes.ts @@ -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), +})); diff --git a/server/src/__tests__/http-log-redaction.test.ts b/server/src/__tests__/http-log-redaction.test.ts index a16e56e69a5..8644f339e55 100644 --- a/server/src/__tests__/http-log-redaction.test.ts +++ b/server/src/__tests__/http-log-redaction.test.ts @@ -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"); diff --git a/server/src/__tests__/issue-internal-notes.test.ts b/server/src/__tests__/issue-internal-notes.test.ts new file mode 100644 index 00000000000..006b2065450 --- /dev/null +++ b/server/src/__tests__/issue-internal-notes.test.ts @@ -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>, db: ReturnType; + 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(); + }); +}); diff --git a/server/src/__tests__/slack-conversation-lifecycle.test.ts b/server/src/__tests__/slack-conversation-lifecycle.test.ts index 1162f1137ff..a38a48aaeb1 100644 --- a/server/src/__tests__/slack-conversation-lifecycle.test.ts +++ b/server/src/__tests__/slack-conversation-lifecycle.test.ts @@ -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" })]); }); diff --git a/server/src/app.ts b/server/src/app.ts index 439350c09b9..de358ea9b57 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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, diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index 38ad1146f19..5a615fbca7d 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -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); diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index 6de3f28ed57..ecc715271b9 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -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"; diff --git a/server/src/routes/issue-internal-notes.ts b/server/src/routes/issue-internal-notes.ts new file mode 100644 index 00000000000..666f1a0ea88 --- /dev/null +++ b/server/src/routes/issue-internal-notes.ts @@ -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; +} diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 94ec1e0d0f9..99c3e05447d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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", diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ab80dfe30b7..34eafed5f84 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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()); } } diff --git a/ui/src/api/internalNotes.ts b/ui/src/api/internalNotes.ts new file mode 100644 index 00000000000..7adeba78ec1 --- /dev/null +++ b/ui/src/api/internalNotes.ts @@ -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( + `/issues/${issueId}/internal-notes`, { body, clientRequestId }), +}; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 7d8bf562a3e..76be74d1b93 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -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"); diff --git a/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx b/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx index 19a14c75de1..eb4b6e8456a 100644 --- a/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx +++ b/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx @@ -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"); }); diff --git a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx index 0d853317455..ae28402e7a6 100644 --- a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx +++ b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx @@ -110,7 +110,7 @@ const filePhaseLabels: Record = { }; 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 && upgradeThreads.mutate()} />} - {binding.bindingMode === "slack_dm_thread_v2" &&

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.

} - {binding.slackReplyAvailable && 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.

} + {binding.slackReplyAvailable && binding.bindingMode !== "slack_dm_thread_v2" && } {composing && (
@@ -842,7 +843,7 @@ function ConnectedTaskComposer({ )}

- 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."}

+ + Internal notes + 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. + +
+ {notes.isPending &&

Loading notes…

} + {notes.isError &&

Could not load notes.

} + {notes.data?.pages.flatMap(page => page.notes).map(note =>
+

{note.authorUserId === userId ? "You" : "Team member"} · {new Date(note.createdAt).toLocaleString()}

+

{note.body}

+
)} + {notes.data?.pages[0]?.notes.length === 0 &&

No Internal notes yet.

} + {notes.hasNextPage && } +
+ +