diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 0ac73ce692..bc23cf4de2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -874,7 +874,7 @@ describe("worktree helpers", () => { } }); - it("reseed preserves the current worktree ports, instance id, and branding", async () => { + itEmbeddedPostgres("reseed preserves the current worktree ports, instance id, and branding", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-")); const repoRoot = path.join(tempRoot, "repo"); const sourceRoot = path.join(tempRoot, "source"); diff --git a/doc/plans/2026-04-24-external-object-reference-backfill.md b/doc/plans/2026-04-24-external-object-reference-backfill.md new file mode 100644 index 0000000000..02b8e86730 --- /dev/null +++ b/doc/plans/2026-04-24-external-object-reference-backfill.md @@ -0,0 +1,28 @@ +# External Object Reference Backfill + +## Purpose + +Backfill existing issue titles, descriptions, comments, and documents into `external_object_mentions` after the Phase 3 detector registry and mention sync service exist. + +## Command Shape + +Add a command parallel to the internal issue-reference backfill: + +```sh +pnpm external-objects:backfill +pnpm external-objects:backfill -- --company +pnpm external-objects:backfill -- --dry-run +``` + +## Required Behavior + +- Scan issue title and description sources, issue comments, and issue documents through the same external-object mention sync service used by write hooks. +- Preserve company boundaries: every scan query, mention upsert, object upsert, and dry-run summary must include `company_id`. +- Use shared URL extraction and canonicalization helpers so raw URLs, URL userinfo, query strings, and fragments are never persisted. +- Replace mentions per source instead of appending, using the same source key shape as live write sync. +- Report counts by company, source kind, provider key, object type, skipped userinfo URLs, and unresolved URLs. +- Default to a non-interactive safe run; `--dry-run` must avoid writes while still reporting what would change. + +## Phase 3 Hook + +The command should be implemented only after the detector registry can classify URLs and the service can upsert mentions/placeholders consistently. Until then, Phase 2 owns the schema and shared helpers that make the command safe. diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 5f8380f7b1..b541ff5caa 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -12,6 +12,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i - Plugin UI runs as same-origin JavaScript inside the main Paperclip app. - Worker-side host APIs are capability-gated. - Plugin UI is not sandboxed by manifest capabilities. +- External object reference providers are trusted-install only in the MVP. + Capabilities gate provider detection/resolution and host API calls, but they + are not a sandbox boundary for untrusted marketplace code. - Plugin database migrations are restricted to a host-derived plugin namespace. - Plugin-managed surfaces are first-class records (agents, projects, routines, and skills) rather than private plugin-only state. @@ -22,6 +25,29 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i building custom versions. - `ctx.assets` is not supported in the current runtime. +## External object reference providers + +Plugins can contribute provider-neutral object reference detection and status +resolution for URLs and future explicit links. Declare `objectReferences` in the +manifest and add at least `external.objects.detect` and `external.objects.read`. + +```ts +objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + }, +], +``` + +Implement `onDetectExternalObjects()` in the worker to recognize sanitized URL +candidates and return provider-stable identities. Implement +`onResolveExternalObject()` to return normalized board-safe status metadata. +Paperclip owns inline markdown rendering; plugins must not return React, HTML, +or `dangerouslySetInnerHTML` content for inline references. + ## Scaffold a plugin Use the CLI scaffold command: diff --git a/packages/db/src/external-objects-schema.test.ts b/packages/db/src/external-objects-schema.test.ts new file mode 100644 index 0000000000..897a98d726 --- /dev/null +++ b/packages/db/src/external-objects-schema.test.ts @@ -0,0 +1,47 @@ +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { externalObjectMentions } from "./schema/external_object_mentions.js"; +import { externalObjects } from "./schema/external_objects.js"; + +function indexColumns(table: Parameters[0], indexName: string): string[] { + const index = getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName); + if (!index) return []; + return index.config.columns.map((column) => (column as { name: string }).name); +} + +describe("external object reference schema", () => { + it("scopes external object uniqueness by company", () => { + expect(indexColumns(externalObjects, "external_objects_company_external_id_uq")).toEqual([ + "company_id", + "provider_key", + "object_type", + "external_id", + ]); + expect(indexColumns(externalObjects, "external_objects_company_identity_uq")).toEqual([ + "company_id", + "provider_key", + "object_type", + "canonical_identity_hash", + ]); + }); + + it("indexes status, refresh scheduling, source issue, and object lookups by company", () => { + expect(indexColumns(externalObjects, "external_objects_company_provider_status_idx")).toEqual([ + "company_id", + "provider_key", + "status_category", + ]); + expect(indexColumns(externalObjects, "external_objects_company_refresh_idx")).toEqual([ + "company_id", + "next_refresh_at", + ]); + expect(indexColumns(externalObjectMentions, "external_object_mentions_company_source_issue_idx")).toEqual([ + "company_id", + "source_issue_id", + ]); + expect(indexColumns(externalObjectMentions, "external_object_mentions_company_object_idx")).toEqual([ + "company_id", + "object_id", + ]); + }); +}); diff --git a/packages/db/src/migrations/0106_external_object_references.sql b/packages/db/src/migrations/0106_external_object_references.sql new file mode 100644 index 0000000000..070a36b452 --- /dev/null +++ b/packages/db/src/migrations/0106_external_object_references.sql @@ -0,0 +1,106 @@ +CREATE TABLE IF NOT EXISTS "external_objects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "provider_key" text NOT NULL, + "plugin_id" uuid, + "object_type" text NOT NULL, + "external_id" text NOT NULL, + "sanitized_canonical_url" text, + "canonical_identity_hash" text, + "display_title" text, + "status_key" text, + "status_label" text, + "status_category" text DEFAULT 'unknown' NOT NULL, + "status_tone" text DEFAULT 'neutral' NOT NULL, + "liveness" text DEFAULT 'unknown' NOT NULL, + "is_terminal" boolean DEFAULT false NOT NULL, + "data" jsonb DEFAULT '{}'::jsonb NOT NULL, + "remote_version" text, + "etag" text, + "last_resolved_at" timestamp with time zone, + "last_changed_at" timestamp with time zone, + "last_error_at" timestamp with time zone, + "next_refresh_at" timestamp with time zone, + "last_error_code" text, + "last_error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "external_object_mentions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "source_issue_id" uuid NOT NULL, + "source_kind" text NOT NULL, + "source_record_id" uuid, + "document_key" text, + "property_key" text, + "matched_text_redacted" text, + "sanitized_display_url" text, + "canonical_identity_hash" text, + "canonical_identity" jsonb, + "object_id" uuid, + "provider_key" text, + "detector_key" text, + "object_type" text, + "confidence" text DEFAULT 'exact' NOT NULL, + "created_by_plugin_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "external_objects" ADD CONSTRAINT "external_objects_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "external_objects" ADD CONSTRAINT "external_objects_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_object_id_external_objects_id_fk" FOREIGN KEY ("object_id") REFERENCES "public"."external_objects"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "external_object_mentions" ADD CONSTRAINT "external_object_mentions_created_by_plugin_id_plugins_id_fk" FOREIGN KEY ("created_by_plugin_id") REFERENCES "public"."plugins"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "external_objects_company_provider_object_idx" ON "external_objects" USING btree ("company_id","provider_key","object_type"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "external_objects_company_provider_status_idx" ON "external_objects" USING btree ("company_id","provider_key","status_category"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "external_objects_company_refresh_idx" ON "external_objects" USING btree ("company_id","next_refresh_at"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "external_objects_company_external_id_uq" ON "external_objects" USING btree ("company_id","provider_key","object_type","external_id"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "external_objects_company_identity_uq" ON "external_objects" USING btree ("company_id","provider_key","object_type","canonical_identity_hash"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "external_object_mentions_company_source_issue_idx" ON "external_object_mentions" USING btree ("company_id","source_issue_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "external_object_mentions_company_object_idx" ON "external_object_mentions" USING btree ("company_id","object_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "external_object_mentions_company_provider_idx" ON "external_object_mentions" USING btree ("company_id","provider_key","object_type"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "external_object_mentions_company_source_record_uq" ON "external_object_mentions" USING btree ("company_id","source_issue_id","source_kind","source_record_id","document_key","property_key","canonical_identity_hash") WHERE "source_record_id" is not null and "canonical_identity_hash" is not null; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "external_object_mentions_company_source_null_record_uq" ON "external_object_mentions" USING btree ("company_id","source_issue_id","source_kind","document_key","property_key","canonical_identity_hash") WHERE "source_record_id" is null and "canonical_identity_hash" is not null; diff --git a/packages/db/src/migrations/0107_external_object_display_metadata.sql b/packages/db/src/migrations/0107_external_object_display_metadata.sql new file mode 100644 index 0000000000..3888f9ea1f --- /dev/null +++ b/packages/db/src/migrations/0107_external_object_display_metadata.sql @@ -0,0 +1,5 @@ +ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "display_key" text; +--> statement-breakpoint +ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "icon_key" text; +--> statement-breakpoint +ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "status_icon_key" text; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index ae94c3645b..06099862a1 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -743,6 +743,20 @@ "when": 1781902000000, "tag": "0105_instance_scoped_environments", "breakpoints": true + }, + { + "idx": 106, + "version": "7", + "when": 1781902100000, + "tag": "0106_external_object_references", + "breakpoints": true + }, + { + "idx": 107, + "version": "7", + "when": 1782165200000, + "tag": "0107_external_object_display_metadata", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/external_object_mentions.ts b/packages/db/src/schema/external_object_mentions.ts new file mode 100644 index 0000000000..95cb8e9c80 --- /dev/null +++ b/packages/db/src/schema/external_object_mentions.ts @@ -0,0 +1,61 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import type { ExternalObjectMentionConfidence, ExternalObjectMentionSourceKind } from "@paperclipai/shared"; +import { companies } from "./companies.js"; +import { externalObjects } from "./external_objects.js"; +import { issues } from "./issues.js"; +import { plugins } from "./plugins.js"; + +export const externalObjectMentions = pgTable( + "external_object_mentions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + sourceIssueId: uuid("source_issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + sourceKind: text("source_kind").$type().notNull(), + sourceRecordId: uuid("source_record_id"), + documentKey: text("document_key"), + propertyKey: text("property_key"), + matchedTextRedacted: text("matched_text_redacted"), + sanitizedDisplayUrl: text("sanitized_display_url"), + canonicalIdentityHash: text("canonical_identity_hash"), + canonicalIdentity: jsonb("canonical_identity").$type>(), + objectId: uuid("object_id").references(() => externalObjects.id, { onDelete: "set null" }), + providerKey: text("provider_key"), + detectorKey: text("detector_key"), + objectType: text("object_type"), + confidence: text("confidence").$type().notNull().default("exact"), + createdByPluginId: uuid("created_by_plugin_id").references(() => plugins.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySourceIssueIdx: index("external_object_mentions_company_source_issue_idx").on( + table.companyId, + table.sourceIssueId, + ), + companyObjectIdx: index("external_object_mentions_company_object_idx").on(table.companyId, table.objectId), + companyProviderIdx: index("external_object_mentions_company_provider_idx").on( + table.companyId, + table.providerKey, + table.objectType, + ), + companySourceMentionWithRecordUq: uniqueIndex("external_object_mentions_company_source_record_uq").on( + table.companyId, + table.sourceIssueId, + table.sourceKind, + table.sourceRecordId, + table.documentKey, + table.propertyKey, + table.canonicalIdentityHash, + ).where(sql`${table.sourceRecordId} is not null and ${table.canonicalIdentityHash} is not null`), + companySourceMentionWithoutRecordUq: uniqueIndex("external_object_mentions_company_source_null_record_uq").on( + table.companyId, + table.sourceIssueId, + table.sourceKind, + table.documentKey, + table.propertyKey, + table.canonicalIdentityHash, + ).where(sql`${table.sourceRecordId} is null and ${table.canonicalIdentityHash} is not null`), + }), +); diff --git a/packages/db/src/schema/external_objects.ts b/packages/db/src/schema/external_objects.ts new file mode 100644 index 0000000000..867702987d --- /dev/null +++ b/packages/db/src/schema/external_objects.ts @@ -0,0 +1,77 @@ +import { + boolean, + index, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, + ExternalObjectStatusTone, +} from "@paperclipai/shared"; +import { companies } from "./companies.js"; +import { plugins } from "./plugins.js"; + +export const externalObjects = pgTable( + "external_objects", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + providerKey: text("provider_key").notNull(), + pluginId: uuid("plugin_id").references(() => plugins.id, { onDelete: "set null" }), + objectType: text("object_type").notNull(), + externalId: text("external_id").notNull(), + sanitizedCanonicalUrl: text("sanitized_canonical_url"), + canonicalIdentityHash: text("canonical_identity_hash"), + displayKey: text("display_key"), + iconKey: text("icon_key"), + displayTitle: text("display_title"), + statusKey: text("status_key"), + statusLabel: text("status_label"), + statusIconKey: text("status_icon_key"), + statusCategory: text("status_category").$type().notNull().default("unknown"), + statusTone: text("status_tone").$type().notNull().default("neutral"), + liveness: text("liveness").$type().notNull().default("unknown"), + isTerminal: boolean("is_terminal").notNull().default(false), + data: jsonb("data").$type>().notNull().default({}), + remoteVersion: text("remote_version"), + etag: text("etag"), + lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }), + lastChangedAt: timestamp("last_changed_at", { withTimezone: true }), + lastErrorAt: timestamp("last_error_at", { withTimezone: true }), + nextRefreshAt: timestamp("next_refresh_at", { withTimezone: true }), + lastErrorCode: text("last_error_code"), + lastErrorMessage: text("last_error_message"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyProviderObjectIdx: index("external_objects_company_provider_object_idx").on( + table.companyId, + table.providerKey, + table.objectType, + ), + companyProviderStatusIdx: index("external_objects_company_provider_status_idx").on( + table.companyId, + table.providerKey, + table.statusCategory, + ), + companyRefreshIdx: index("external_objects_company_refresh_idx").on(table.companyId, table.nextRefreshAt), + companyExternalIdUq: uniqueIndex("external_objects_company_external_id_uq").on( + table.companyId, + table.providerKey, + table.objectType, + table.externalId, + ), + companyCanonicalIdentityUq: uniqueIndex("external_objects_company_identity_uq").on( + table.companyId, + table.providerKey, + table.objectType, + table.canonicalIdentityHash, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index cda88aedbe..5b2d9e1a49 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -36,6 +36,8 @@ export { issueWatchdogs } from "./issue_watchdogs.js"; export { issuePlanDecompositions } from "./issue_plan_decompositions.js"; export { issueRecoveryActions } from "./issue_recovery_actions.js"; export { issueReferenceMentions } from "./issue_reference_mentions.js"; +export { externalObjects } from "./external_objects.js"; +export { externalObjectMentions } from "./external_object_mentions.js"; export { issueRelations } from "./issue_relations.js"; export { routines, routineRevisions, routineTriggers, routineRuns } from "./routines.js"; export { issueWorkProducts } from "./issue_work_products.js"; diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 020b0b8ded..3a8b866d14 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -341,6 +341,10 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `telemetry.track` | | | `database.namespace.migrate` | | | `database.namespace.write` | +| | `external.objects.detect` | +| | `external.objects.read` | +| | `external.objects.write` | +| | `external.objects.refresh` | | **Instance** | `instance.settings.register` | | | `plugin.state.read` | | | `plugin.state.write` | @@ -372,6 +376,42 @@ Declare in `manifest.capabilities`. Grouped by scope: Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`. +### External Object Reference Providers + +Trusted connector plugins can declare generic external object providers in the +manifest. The host owns URL scanning, sanitized canonical URLs, core storage, +normalized status rendering, and issue/comment/document write durability. The +plugin only identifies provider-owned objects and resolves board-safe status +metadata. + +```ts +objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 }, + }, +], +capabilities: ["external.objects.detect", "external.objects.read"], +``` + +Implement `onDetectExternalObjects()` to map sanitized URL candidates to +`providerKey`, `objectType`, provider-stable `externalId`, and optional display +metadata such as `displayKey`/`iconKey`. Implement `onResolveExternalObject()` +to return a normalized snapshot with `statusCategory`, `statusTone`, +`statusLabel`, optional `statusIconKey`, board-safe `data`, and freshness +metadata. Slow or failing plugins are isolated: Paperclip logs the failure and +continues saving the source issue, comment, or document. + +MVP security posture: provider plugins are trusted installs. Manifest +capabilities gate host APIs and provider invocation paths, but they are not a +sandbox boundary for untrusted marketplace code. Plugin UI is same-origin +JavaScript and must not be mounted inline in markdown; inline external-object +rendering uses host-owned metadata only. Treat untrusted providers as future work +that requires worker sandboxing plus isolated plugin UI. + ### Restricted Database Namespace Trusted orchestration plugins can declare a host-owned PostgreSQL namespace: diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index e13b939acf..b0e4ecff6e 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -62,6 +62,12 @@ import type { PluginEnvironmentResumeLeaseParams, PluginEnvironmentValidateConfigParams, PluginEnvironmentValidationResult, + DetectExternalObjectsParams, + DetectExternalObjectsResult, + ResolveExternalObjectParams, + PluginExternalObjectResolveResult, + RefreshExternalObjectsParams, + RefreshExternalObjectsResult, } from "./protocol.js"; // --------------------------------------------------------------------------- @@ -243,6 +249,39 @@ export interface PluginDefinition { * access, capabilities, and checkout policy. */ onApiRequest?(input: PluginApiRequestInput): Promise; + + /** + * Called when Paperclip scans issue/comment/document content and asks this + * plugin whether any sanitized URL candidates belong to its external object + * providers. The host has already stripped URL userinfo, query strings, and + * fragments unless provider-safe identity components were explicitly hashed. + * + * Requires `external.objects.detect`. + */ + onDetectExternalObjects?( + params: DetectExternalObjectsParams, + ): Promise; + + /** + * Called when Paperclip needs the current normalized status for one external + * object owned by a manifest-declared provider. + * + * Requires `external.objects.read`. + */ + onResolveExternalObject?( + params: ResolveExternalObjectParams, + ): Promise; + + /** + * Optional batch resolver used by providers that can refresh many objects + * more efficiently than individual `onResolveExternalObject` calls. + * + * Requires `external.objects.refresh`. + */ + onRefreshExternalObjects?( + params: RefreshExternalObjectsParams, + ): Promise; + /** * Called to validate provider-specific configuration for a plugin-hosted * environment driver. diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 63ec9c7023..1339edeafc 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -155,6 +155,17 @@ export type { PluginPerformActionActorContext, PluginPerformActionContext, ExecuteToolParams, + PluginExternalObjectUrlCandidate, + PluginExternalObjectSourceContext, + DetectExternalObjectsParams, + PluginExternalObjectDetection, + DetectExternalObjectsResult, + PluginExternalObjectRecordSnapshot, + ResolveExternalObjectParams, + PluginExternalObjectResolvedSnapshot, + PluginExternalObjectResolveResult, + RefreshExternalObjectsParams, + RefreshExternalObjectsResult, PluginEnvironmentDiagnostic, PluginEnvironmentDriverBaseParams, PluginEnvironmentValidateConfigParams, @@ -313,6 +324,8 @@ export type { PluginApiRouteDeclaration, PluginLocalFolderDeclaration, PluginCompanySettings, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PluginRecord, PluginDatabaseNamespaceRecord, PluginMigrationRecord, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index a3925049a7..c56a0dd23e 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -40,6 +40,11 @@ import type { Goal, PluginLocalFolderDeclaration, PrincipalPermissionGrant, + ExternalObjectStatusCategory, + ExternalObjectStatusTone, + ExternalObjectLivenessState, + ExternalObjectMentionConfidence, + ExternalObjectMentionSourceKind, } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; @@ -432,6 +437,113 @@ export interface ExecuteToolParams { runContext: ToolRunContext; } +export interface PluginExternalObjectUrlCandidate { + sanitizedCanonicalUrl: string; + sanitizedDisplayUrl: string; + canonicalIdentityHash: string; + canonicalIdentity: Record; + redactedMatchedText: string; +} + +export interface PluginExternalObjectSourceContext { + companyId: string; + sourceIssueId: string; + sourceKind: ExternalObjectMentionSourceKind; + sourceRecordId: string | null; + documentKey: string | null; + propertyKey: string | null; +} + +export interface DetectExternalObjectsParams { + companyId: string; + urls: PluginExternalObjectUrlCandidate[]; + sourceContext: PluginExternalObjectSourceContext; +} + +export interface PluginExternalObjectDetection { + urlIdentityHash: string; + providerKey: string; + objectType: string; + externalId: string; + displayKey?: string | null; + iconKey?: string | null; + displayTitle?: string | null; + confidence?: ExternalObjectMentionConfidence; +} + +export interface DetectExternalObjectsResult { + detections: PluginExternalObjectDetection[]; +} + +export interface PluginExternalObjectRecordSnapshot { + id: string; + companyId: string; + providerKey: string; + objectType: string; + externalId: string; + sanitizedCanonicalUrl: string | null; + canonicalIdentityHash: string | null; + displayKey: string | null; + iconKey: string | null; + displayTitle: string | null; + statusKey: string | null; + statusLabel: string | null; + statusIconKey: string | null; + statusCategory: ExternalObjectStatusCategory; + statusTone: ExternalObjectStatusTone; + liveness: ExternalObjectLivenessState; + isTerminal: boolean; + data: Record; + remoteVersion: string | null; + etag: string | null; +} + +export interface ResolveExternalObjectParams { + companyId: string; + providerKey: string; + objectType: string; + externalId: string; + object: PluginExternalObjectRecordSnapshot; +} + +export interface PluginExternalObjectResolvedSnapshot { + displayKey?: string | null; + iconKey?: string | null; + displayTitle?: string | null; + statusKey?: string | null; + statusLabel?: string | null; + statusIconKey?: string | null; + statusCategory: ExternalObjectStatusCategory; + statusTone: ExternalObjectStatusTone; + isTerminal?: boolean; + data?: Record; + remoteVersion?: string | null; + etag?: string | null; + ttlSeconds?: number; +} + +export type PluginExternalObjectResolveResult = + | { ok: true; snapshot: PluginExternalObjectResolvedSnapshot } + | { + ok: false; + liveness: Extract; + errorCode: string; + errorMessage?: string | null; + retryAfterSeconds?: number; + }; + +export interface RefreshExternalObjectsParams { + companyId: string; + objects: PluginExternalObjectRecordSnapshot[]; +} + +export interface RefreshExternalObjectsResult { + results: Array<{ + objectId: string; + result: PluginExternalObjectResolveResult; + }>; +} + export interface PluginEnvironmentDiagnostic { severity: "info" | "warning" | "error"; message: string; @@ -604,6 +716,18 @@ export interface HostToWorkerMethods { performAction: [params: PerformActionParams, result: unknown]; /** @see PLUGIN_SPEC.md §13.10 */ executeTool: [params: ExecuteToolParams, result: ToolResult]; + detectExternalObjects: [ + params: DetectExternalObjectsParams, + result: DetectExternalObjectsResult, + ]; + resolveExternalObject: [ + params: ResolveExternalObjectParams, + result: PluginExternalObjectResolveResult, + ]; + refreshExternalObjects: [ + params: RefreshExternalObjectsParams, + result: RefreshExternalObjectsResult, + ]; environmentValidateConfig: [ params: PluginEnvironmentValidateConfigParams, result: PluginEnvironmentValidationResult, @@ -659,6 +783,9 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] "getData", "performAction", "executeTool", + "detectExternalObjects", + "resolveExternalObject", + "refreshExternalObjects", "environmentValidateConfig", "environmentProbe", "environmentAcquireLease", diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 1951205526..e449252cc6 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -84,6 +84,8 @@ export type { PluginDatabaseDeclaration, PluginApiRouteDeclaration, PluginApiRouteCompanyResolution, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PluginRecord, PluginDatabaseNamespaceRecord, PluginMigrationRecord, diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index cbb223d96c..cded95a82d 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -82,6 +82,9 @@ import type { PluginPerformActionActorContext, PluginPerformActionContext, ExecuteToolParams, + DetectExternalObjectsParams, + ResolveExternalObjectParams, + RefreshExternalObjectsParams, PluginEnvironmentAcquireLeaseParams, PluginEnvironmentDestroyLeaseParams, PluginEnvironmentExecuteParams, @@ -1351,6 +1354,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost case "executeTool": return handleExecuteTool(params as ExecuteToolParams); + case "detectExternalObjects": + return handleDetectExternalObjects(params as DetectExternalObjectsParams); + case "resolveExternalObject": + return handleResolveExternalObject(params as ResolveExternalObjectParams); + case "refreshExternalObjects": + return handleRefreshExternalObjects(params as RefreshExternalObjectsParams); case "environmentValidateConfig": return handleEnvironmentValidateConfig(params as PluginEnvironmentValidateConfigParams); @@ -1409,6 +1418,9 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (plugin.definition.onHealth) supportedMethods.push("health"); if (plugin.definition.onShutdown) supportedMethods.push("shutdown"); if (plugin.definition.onApiRequest) supportedMethods.push("handleApiRequest"); + if (plugin.definition.onDetectExternalObjects) supportedMethods.push("detectExternalObjects"); + if (plugin.definition.onResolveExternalObject) supportedMethods.push("resolveExternalObject"); + if (plugin.definition.onRefreshExternalObjects) supportedMethods.push("refreshExternalObjects"); if (plugin.definition.onEnvironmentValidateConfig) supportedMethods.push("environmentValidateConfig"); if (plugin.definition.onEnvironmentProbe) supportedMethods.push("environmentProbe"); if (plugin.definition.onEnvironmentAcquireLease) supportedMethods.push("environmentAcquireLease"); @@ -1588,6 +1600,27 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return entry.fn(params.parameters, params.runContext); } + async function handleDetectExternalObjects(params: DetectExternalObjectsParams) { + if (!plugin.definition.onDetectExternalObjects) { + throw methodNotImplemented("detectExternalObjects"); + } + return plugin.definition.onDetectExternalObjects(params); + } + + async function handleResolveExternalObject(params: ResolveExternalObjectParams) { + if (!plugin.definition.onResolveExternalObject) { + throw methodNotImplemented("resolveExternalObject"); + } + return plugin.definition.onResolveExternalObject(params); + } + + async function handleRefreshExternalObjects(params: RefreshExternalObjectsParams) { + if (!plugin.definition.onRefreshExternalObjects) { + throw methodNotImplemented("refreshExternalObjects"); + } + return plugin.definition.onRefreshExternalObjects(params); + } + function methodNotImplemented(method: string): Error & { code: number } { return Object.assign( new Error(`${method} is not implemented by this plugin`), diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 954fa76a9d..040dc348f5 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -358,6 +358,53 @@ export const DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES = [ export type DocumentAnnotationAnchorConfidence = (typeof DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES)[number]; +export const EXTERNAL_OBJECT_STATUS_CATEGORIES = [ + "unknown", + "open", + "waiting", + "running", + "succeeded", + "failed", + "blocked", + "closed", + "archived", + "auth_required", + "unreachable", +] as const; +export type ExternalObjectStatusCategory = (typeof EXTERNAL_OBJECT_STATUS_CATEGORIES)[number]; + +export const EXTERNAL_OBJECT_STATUS_TONES = [ + "neutral", + "info", + "success", + "warning", + "danger", + "muted", +] as const; +export type ExternalObjectStatusTone = (typeof EXTERNAL_OBJECT_STATUS_TONES)[number]; + +export const EXTERNAL_OBJECT_LIVENESS_STATES = [ + "unknown", + "fresh", + "stale", + "auth_required", + "unreachable", +] as const; +export type ExternalObjectLivenessState = (typeof EXTERNAL_OBJECT_LIVENESS_STATES)[number]; + +export const EXTERNAL_OBJECT_MENTION_SOURCE_KINDS = [ + "title", + "description", + "comment", + "document", + "property", + "plugin", +] as const; +export type ExternalObjectMentionSourceKind = (typeof EXTERNAL_OBJECT_MENTION_SOURCE_KINDS)[number]; + +export const EXTERNAL_OBJECT_MENTION_CONFIDENCES = ["exact", "likely", "possible"] as const; +export type ExternalObjectMentionConfidence = (typeof EXTERNAL_OBJECT_MENTION_CONFIDENCES)[number]; + export const ISSUE_EXECUTION_POLICY_MODES = ["normal", "auto"] as const; export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[number]; @@ -677,6 +724,7 @@ export const LIVE_EVENT_TYPES = [ "heartbeat.run.log", "agent.status", "activity.logged", + "external_object.updated", "plugin.ui.updated", "plugin.worker.crashed", "plugin.worker.restarted", @@ -851,6 +899,10 @@ export const PLUGIN_CAPABILITIES = [ "telemetry.track", "database.namespace.migrate", "database.namespace.write", + "external.objects.detect", + "external.objects.read", + "external.objects.write", + "external.objects.refresh", // Plugin State "plugin.state.read", "plugin.state.write", diff --git a/packages/shared/src/external-objects-server.ts b/packages/shared/src/external-objects-server.ts new file mode 100644 index 0000000000..f323ba3e5e --- /dev/null +++ b/packages/shared/src/external-objects-server.ts @@ -0,0 +1,217 @@ +import { createHash } from "node:crypto"; +import { parseIssueReferenceHref } from "./issue-references.js"; +import type { + ExternalObjectCanonicalIdentity, + ExternalObjectCanonicalUrl, + ExternalObjectMentionSource, + ExternalObjectUrlCanonicalizationOptions, + ExternalObjectUrlMatch, +} from "./external-objects.js"; + +const EXTERNAL_URL_TOKEN_RE = /https?:\/\/[^\s<>()]+/gi; + +function preserveNewlinesAsWhitespace(value: string) { + return value.replace(/[^\n]/g, " "); +} + +function stripMarkdownCode(markdown: string): string { + if (!markdown) return ""; + + let output = ""; + let index = 0; + + while (index < markdown.length) { + const remaining = markdown.slice(index); + const fenceMatch = /^(?:```+|~~~+)/.exec(remaining); + const atLineStart = index === 0 || markdown[index - 1] === "\n"; + + if (atLineStart && fenceMatch) { + const fence = fenceMatch[0]!; + const blockStart = index; + index += fence.length; + while (index < markdown.length && markdown[index] !== "\n") index += 1; + if (index < markdown.length) index += 1; + + while (index < markdown.length) { + const lineStart = index === 0 || markdown[index - 1] === "\n"; + if (lineStart && markdown.startsWith(fence, index)) { + index += fence.length; + while (index < markdown.length && markdown[index] !== "\n") index += 1; + if (index < markdown.length) index += 1; + break; + } + index += 1; + } + + output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index)); + continue; + } + + if (markdown[index] === "`") { + let tickCount = 1; + while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") { + tickCount += 1; + } + const fence = "`".repeat(tickCount); + const inlineStart = index; + index += tickCount; + const closeIndex = markdown.indexOf(fence, index); + if (closeIndex === -1) { + output += markdown.slice(inlineStart, inlineStart + tickCount); + index = inlineStart + tickCount; + continue; + } + index = closeIndex + tickCount; + output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index)); + continue; + } + + output += markdown[index]!; + index += 1; + } + + return output; +} + +function trimTrailingPunctuation(token: string): string { + let trimmed = token; + while (trimmed.length > 0) { + const last = trimmed[trimmed.length - 1]!; + if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break; + + if ( + (last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0)) + || (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0)) + ) { + break; + } + trimmed = trimmed.slice(0, -1); + } + return trimmed; +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function normalizePathname(pathname: string): string { + return pathname || "/"; +} + +export function findExternalObjectUrlMatches(markdown: string): ExternalObjectUrlMatch[] { + if (!markdown) return []; + + const scrubbed = stripMarkdownCode(markdown); + const matches: ExternalObjectUrlMatch[] = []; + let match: RegExpExecArray | null; + const re = new RegExp(EXTERNAL_URL_TOKEN_RE); + + while ((match = re.exec(scrubbed)) !== null) { + const matchedText = trimTrailingPunctuation(match[0]); + if (!matchedText || parseIssueReferenceHref(matchedText)) continue; + + matches.push({ + index: match.index, + length: matchedText.length, + matchedText, + }); + } + + return matches; +} + +export function canonicalizeExternalObjectUrl( + value: string, + options: ExternalObjectUrlCanonicalizationOptions = {}, +): ExternalObjectCanonicalUrl | null { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + + const scheme = url.protocol === "https:" ? "https" : "http"; + const path = normalizePathname(url.pathname); + const sanitizedCanonicalUrl = `${scheme}://${url.host.toLowerCase()}${path}`; + const identityQueryParams = new Set(options.identityQueryParams ?? []); + const queryParamHashes: Record = {}; + + for (const key of [...identityQueryParams].sort()) { + const values = url.searchParams.getAll(key); + if (values.length === 0) continue; + queryParamHashes[key] = sha256Hex(values.join("\u0000")); + } + + const canonicalIdentity: ExternalObjectCanonicalIdentity = { + scheme, + host: url.host.toLowerCase(), + path, + ...(Object.keys(queryParamHashes).length > 0 ? { queryParamHashes } : {}), + }; + + return { + sanitizedCanonicalUrl, + sanitizedDisplayUrl: sanitizedCanonicalUrl, + canonicalIdentity, + canonicalIdentityHash: sha256Hex(stableStringify(canonicalIdentity)), + redactedMatchedText: sanitizedCanonicalUrl, + }; +} + +export function extractExternalObjectCanonicalUrls( + markdown: string, + options: ExternalObjectUrlCanonicalizationOptions = {}, +): ExternalObjectCanonicalUrl[] { + const seen = new Set(); + const ordered: ExternalObjectCanonicalUrl[] = []; + + for (const match of findExternalObjectUrlMatches(markdown)) { + const canonical = canonicalizeExternalObjectUrl(match.matchedText, options); + if (!canonical || seen.has(canonical.canonicalIdentityHash)) continue; + seen.add(canonical.canonicalIdentityHash); + ordered.push(canonical); + } + + return ordered; +} + +export function buildExternalObjectScopedIdentityKey(args: { + companyId: string; + providerKey: string; + objectType: string; + canonicalIdentityHash: string; +}): string { + return [args.companyId, args.providerKey, args.objectType, args.canonicalIdentityHash].join(":"); +} + +export function buildExternalObjectMentionSourceKey(source: Required> & ExternalObjectMentionSource): string { + return [ + source.companyId, + source.sourceIssueId, + source.sourceKind, + source.sourceRecordId ?? "", + source.documentKey ?? "", + source.propertyKey ?? "", + ].join(":"); +} diff --git a/packages/shared/src/external-objects.test.ts b/packages/shared/src/external-objects.test.ts new file mode 100644 index 0000000000..aa55bd97d9 --- /dev/null +++ b/packages/shared/src/external-objects.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { formatExternalObjectMentionSourceLabel } from "./external-objects.js"; +import { + buildExternalObjectMentionSourceKey, + buildExternalObjectScopedIdentityKey, + canonicalizeExternalObjectUrl, + extractExternalObjectCanonicalUrls, + findExternalObjectUrlMatches, +} from "./external-objects-server.js"; +import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./validators/external-object.js"; + +describe("external object references", () => { + it("extracts external urls without changing internal issue reference behavior", () => { + expect( + findExternalObjectUrlMatches( + "See PAP-1, /issues/PAP-2, https://paperclip.ing/PAP/issues/PAP-3, and https://github.com/acme/app/pull/4.", + ), + ).toEqual([{ index: 70, length: 34, matchedText: "https://github.com/acme/app/pull/4" }]); + }); + + it("ignores urls inside inline and fenced code", () => { + const markdown = [ + "Use https://github.com/acme/app/pull/1 here.", + "`https://github.com/acme/app/pull/2` should not count.", + "```", + "https://github.com/acme/app/pull/3", + "```", + ].join("\n"); + + expect(findExternalObjectUrlMatches(markdown).map((match) => match.matchedText)).toEqual([ + "https://github.com/acme/app/pull/1", + ]); + }); + + it("canonicalizes urls by stripping query and fragment by default", () => { + expect(canonicalizeExternalObjectUrl("HTTPS://GitHub.com/acme/app/pull/1?token=secret#discussion")).toMatchObject({ + sanitizedCanonicalUrl: "https://github.com/acme/app/pull/1", + sanitizedDisplayUrl: "https://github.com/acme/app/pull/1", + redactedMatchedText: "https://github.com/acme/app/pull/1", + canonicalIdentity: { + scheme: "https", + host: "github.com", + path: "/acme/app/pull/1", + }, + }); + }); + + it("rejects urls with userinfo", () => { + expect(canonicalizeExternalObjectUrl("https://token:secret@github.com/acme/app/pull/1")).toBeNull(); + }); + + it("hashes provider-required query identity values without storing plaintext", () => { + const first = canonicalizeExternalObjectUrl("https://deploy.test/run?id=secret-run&token=drop", { + identityQueryParams: ["id"], + }); + const second = canonicalizeExternalObjectUrl("https://deploy.test/run?id=secret-run&token=other", { + identityQueryParams: ["id"], + }); + + expect(first?.sanitizedCanonicalUrl).toBe("https://deploy.test/run"); + expect(first?.canonicalIdentity.queryParamHashes?.id).toHaveLength(64); + expect(first?.canonicalIdentity.queryParamHashes?.id).not.toContain("secret-run"); + expect(second?.canonicalIdentityHash).toBe(first?.canonicalIdentityHash); + }); + + it("dedupes extracted canonical urls by canonical identity", () => { + expect( + extractExternalObjectCanonicalUrls( + "https://github.com/acme/app/pull/1?token=a and https://github.com/acme/app/pull/1#discussion", + ).map((entry) => entry.sanitizedCanonicalUrl), + ).toEqual(["https://github.com/acme/app/pull/1"]); + }); + + it("includes company id in scoped object identity keys", () => { + const base = { + providerKey: "github", + objectType: "pull_request", + canonicalIdentityHash: "hash", + }; + + expect(buildExternalObjectScopedIdentityKey({ companyId: "company-a", ...base })).not.toBe( + buildExternalObjectScopedIdentityKey({ companyId: "company-b", ...base }), + ); + }); + + it("builds source keys for replacing mentions from the same source", () => { + const oldMentionSource = buildExternalObjectMentionSourceKey({ + companyId: "company-a", + sourceIssueId: "issue-1", + sourceKind: "comment", + sourceRecordId: "comment-1", + }); + const newMentionSource = buildExternalObjectMentionSourceKey({ + companyId: "company-a", + sourceIssueId: "issue-1", + sourceKind: "comment", + sourceRecordId: "comment-1", + }); + const anotherCompanySource = buildExternalObjectMentionSourceKey({ + companyId: "company-b", + sourceIssueId: "issue-1", + sourceKind: "comment", + sourceRecordId: "comment-1", + }); + + expect(newMentionSource).toBe(oldMentionSource); + expect(anotherCompanySource).not.toBe(oldMentionSource); + }); + + it("formats stable source labels", () => { + expect(formatExternalObjectMentionSourceLabel({ sourceKind: "title" })).toBe("Title"); + expect(formatExternalObjectMentionSourceLabel({ sourceKind: "document", documentKey: "plan" })).toBe( + "Document: plan", + ); + expect(formatExternalObjectMentionSourceLabel({ sourceKind: "property", propertyKey: "pr" })).toBe( + "Property: pr", + ); + }); + + it("validates provider keys and object types", () => { + expect(externalObjectProviderKeySchema.parse("github.enterprise")).toBe("github.enterprise"); + expect(externalObjectTypeSchema.parse("pull_request")).toBe("pull_request"); + expect(externalObjectProviderKeySchema.safeParse("GitHub").success).toBe(false); + expect(externalObjectTypeSchema.safeParse("pull-request").success).toBe(false); + }); +}); diff --git a/packages/shared/src/external-objects.ts b/packages/shared/src/external-objects.ts new file mode 100644 index 0000000000..a9953c4e3f --- /dev/null +++ b/packages/shared/src/external-objects.ts @@ -0,0 +1,52 @@ +import type { ExternalObjectMentionSourceKind } from "./constants.js"; + +export interface ExternalObjectUrlMatch { + index: number; + length: number; + matchedText: string; +} + +export interface ExternalObjectCanonicalIdentity { + scheme: "http" | "https"; + host: string; + path: string; + queryParamHashes?: Record; +} + +export interface ExternalObjectUrlCanonicalizationOptions { + identityQueryParams?: readonly string[]; +} + +export interface ExternalObjectCanonicalUrl { + sanitizedCanonicalUrl: string; + sanitizedDisplayUrl: string; + canonicalIdentity: ExternalObjectCanonicalIdentity; + canonicalIdentityHash: string; + redactedMatchedText: string; +} + +export interface ExternalObjectMentionSource { + companyId?: string; + sourceIssueId?: string; + sourceKind: ExternalObjectMentionSourceKind; + sourceRecordId?: string | null; + documentKey?: string | null; + propertyKey?: string | null; +} + +export function formatExternalObjectMentionSourceLabel(source: ExternalObjectMentionSource): string { + switch (source.sourceKind) { + case "title": + return "Title"; + case "description": + return "Description"; + case "comment": + return "Comment"; + case "document": + return source.documentKey ? `Document: ${source.documentKey}` : "Document"; + case "property": + return source.propertyKey ? `Property: ${source.propertyKey}` : "Property"; + case "plugin": + return "Plugin"; + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 319f484f9f..b42f0bdd58 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -92,6 +92,11 @@ export { DOCUMENT_ANNOTATION_THREAD_STATUSES, DOCUMENT_ANNOTATION_ANCHOR_STATES, DOCUMENT_ANNOTATION_ANCHOR_CONFIDENCES, + EXTERNAL_OBJECT_STATUS_CATEGORIES, + EXTERNAL_OBJECT_STATUS_TONES, + EXTERNAL_OBJECT_LIVENESS_STATES, + EXTERNAL_OBJECT_MENTION_SOURCE_KINDS, + EXTERNAL_OBJECT_MENTION_CONFIDENCES, ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, ISSUE_MONITOR_SCHEDULED_BY, @@ -217,6 +222,11 @@ export { type DocumentAnnotationThreadStatus, type DocumentAnnotationAnchorState, type DocumentAnnotationAnchorConfidence, + type ExternalObjectStatusCategory, + type ExternalObjectStatusTone, + type ExternalObjectLivenessState, + type ExternalObjectMentionSourceKind, + type ExternalObjectMentionConfidence, type IssueExecutionPolicyMode, type IssueExecutionStageType, type IssueMonitorScheduledBy, @@ -522,6 +532,11 @@ export type { DocumentTextRange, UpdateDocumentAnnotationThreadRequest, AttachmentArtifactWorkProductMetadata, + ExternalObject, + ExternalObjectMention, + ExternalObjectMentionGroup, + ExternalObjectSummary, + ExternalObjectSummaryItem, Issue, IssueAssigneeAdapterOverrides, IssueBlockerAttention, @@ -786,6 +801,8 @@ export type { PluginDatabaseDeclaration, PluginApiRouteCompanyResolution, PluginApiRouteDeclaration, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PaperclipPluginManifestV1, PluginRecord, PluginDatabaseNamespaceRecord, @@ -829,6 +846,15 @@ export { type VerifyDocumentAnchorSelectorResult, } from "./document-anchors.js"; +export { + formatExternalObjectMentionSourceLabel, + type ExternalObjectCanonicalIdentity, + type ExternalObjectCanonicalUrl, + type ExternalObjectMentionSource, + type ExternalObjectUrlCanonicalizationOptions, + type ExternalObjectUrlMatch, +} from "./external-objects.js"; + export { sidebarOrderPreferenceSchema, upsertSidebarOrderPreferenceSchema, @@ -924,11 +950,24 @@ export { feedbackTargetTypeSchema, feedbackTraceStatusSchema, feedbackVoteValueSchema, + externalObjectStatusCategorySchema, + externalObjectStatusToneSchema, + externalObjectLivenessStateSchema, + externalObjectMentionSourceKindSchema, + externalObjectMentionConfidenceSchema, + externalObjectProviderKeySchema, + externalObjectTypeSchema, + externalObjectCanonicalIdentitySchema, + externalObjectMentionSourceSchema, upsertIssueFeedbackVoteSchema, type CreateCompany, type UpdateCompany, type UpdateCompanyBranding, type UpsertIssueFeedbackVote, + type ExternalObjectCanonicalIdentityInput, + type ExternalObjectMentionSourceInput, + type ExternalObjectProviderKeyInput, + type ExternalObjectTypeInput, environmentDriverSchema, environmentStatusSchema, environmentLeaseStatusSchema, diff --git a/packages/shared/src/types/external-object.ts b/packages/shared/src/types/external-object.ts new file mode 100644 index 0000000000..feca7de9cd --- /dev/null +++ b/packages/shared/src/types/external-object.ts @@ -0,0 +1,93 @@ +import type { + ExternalObjectLivenessState, + ExternalObjectMentionConfidence, + ExternalObjectMentionSourceKind, + ExternalObjectStatusCategory, + ExternalObjectStatusTone, +} from "../constants.js"; + +export interface ExternalObject { + id: string; + companyId: string; + providerKey: string; + pluginId: string | null; + objectType: string; + externalId: string; + sanitizedCanonicalUrl: string | null; + canonicalIdentityHash: string | null; + displayKey?: string | null; + iconKey?: string | null; + displayTitle: string | null; + statusKey: string | null; + statusLabel: string | null; + statusIconKey?: string | null; + statusCategory: ExternalObjectStatusCategory; + statusTone: ExternalObjectStatusTone; + liveness: ExternalObjectLivenessState; + isTerminal: boolean; + data: Record; + remoteVersion: string | null; + etag: string | null; + lastResolvedAt: string | null; + lastChangedAt: string | null; + lastErrorAt: string | null; + nextRefreshAt: string | null; + lastErrorCode: string | null; + lastErrorMessage: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ExternalObjectMention { + id: string; + companyId: string; + sourceIssueId: string; + sourceKind: ExternalObjectMentionSourceKind; + sourceRecordId: string | null; + documentKey: string | null; + propertyKey: string | null; + matchedTextRedacted: string | null; + sanitizedDisplayUrl: string | null; + canonicalIdentityHash: string | null; + canonicalIdentity: Record | null; + objectId: string | null; + providerKey: string | null; + detectorKey: string | null; + objectType: string | null; + confidence: ExternalObjectMentionConfidence; + createdByPluginId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ExternalObjectMentionGroup { + object: ExternalObject | null; + mentions: ExternalObjectMention[]; + mentionCount: number; + sourceLabels: string[]; +} + +export interface ExternalObjectSummaryItem { + id: string; + providerKey: string; + objectType: string; + displayKey?: string | null; + iconKey?: string | null; + displayTitle: string | null; + statusIconKey?: string | null; + statusCategory: ExternalObjectStatusCategory; + statusTone: ExternalObjectStatusTone; + liveness: ExternalObjectLivenessState; + isTerminal: boolean; +} + +export interface ExternalObjectSummary { + total: number; + byStatusCategory: Record; + byLiveness: Record; + highestSeverity: ExternalObjectStatusTone; + staleCount: number; + authRequiredCount: number; + unreachableCount: number; + objects: ExternalObjectSummaryItem[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 5354d5ea4f..fb358d4b6a 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -265,6 +265,14 @@ export type { CompanyArtifactSource, CompanyArtifactsResponse, } from "./artifact.js"; + +export type { + ExternalObject, + ExternalObjectMention, + ExternalObjectMentionGroup, + ExternalObjectSummary, + ExternalObjectSummaryItem, +} from "./external-object.js"; export type { Issue, IssueWorkMode, @@ -551,6 +559,8 @@ export type { PluginDatabaseDeclaration, PluginApiRouteCompanyResolution, PluginApiRouteDeclaration, + PluginObjectReferenceRefreshPolicy, + PluginObjectReferenceProviderDeclaration, PaperclipPluginManifestV1, PluginRecord, PluginDatabaseNamespaceRecord, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index e4a2db9172..e308e0e2ab 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -53,6 +53,7 @@ export interface InstanceExperimentalSettings { enableIssuePlanDecompositions: boolean; enableExperimentalFileViewer: boolean; enableCloudSync: boolean; + enableExternalObjects: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 366a60ba63..20ea2c2ece 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -504,6 +504,31 @@ export interface PluginApiRouteDeclaration { companyResolution?: PluginApiRouteCompanyResolution; } +export interface PluginObjectReferenceRefreshPolicy { + /** Default freshness window for resolved objects from this provider. */ + defaultTtlSeconds?: number; + /** UI-visible staleness window. Core still stores liveness separately from remote status. */ + staleAfterSeconds?: number; +} + +export interface PluginObjectReferenceProviderDeclaration { + /** Stable provider key such as "github", "linear", or "mocktracker". */ + providerKey: string; + /** Human-readable provider name shown in operator-facing surfaces. */ + displayName: string; + /** Provider object types this plugin can detect and resolve. */ + objectTypes: string[]; + /** + * Human-readable URL patterns this provider recognizes. + * These are metadata for operators and docs; workers still perform detection. + */ + urlPatterns?: string[]; + /** Optional default refresh behavior for this provider. */ + refreshPolicy?: PluginObjectReferenceRefreshPolicy; + /** Optional webhook endpoint keys declared under `webhooks` that can refresh these objects. */ + webhookEndpointKeys?: string[]; +} + // --------------------------------------------------------------------------- // Plugin Manifest V1 // --------------------------------------------------------------------------- @@ -570,6 +595,8 @@ export interface PaperclipPluginManifestV1 { skills?: PluginManagedSkillDeclaration[]; /** Trusted local folders this plugin can configure and access by stable key. */ localFolders?: PluginLocalFolderDeclaration[]; + /** External object reference providers this plugin contributes. */ + objectReferences?: PluginObjectReferenceProviderDeclaration[]; /** * Legacy top-level launcher declarations. * Prefer `ui.launchers` for new manifests. diff --git a/packages/shared/src/validators/external-object.ts b/packages/shared/src/validators/external-object.ts new file mode 100644 index 0000000000..33ae0ce490 --- /dev/null +++ b/packages/shared/src/validators/external-object.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { + EXTERNAL_OBJECT_LIVENESS_STATES, + EXTERNAL_OBJECT_MENTION_CONFIDENCES, + EXTERNAL_OBJECT_MENTION_SOURCE_KINDS, + EXTERNAL_OBJECT_STATUS_CATEGORIES, + EXTERNAL_OBJECT_STATUS_TONES, +} from "../constants.js"; + +export const externalObjectStatusCategorySchema = z.enum(EXTERNAL_OBJECT_STATUS_CATEGORIES); +export const externalObjectStatusToneSchema = z.enum(EXTERNAL_OBJECT_STATUS_TONES); +export const externalObjectLivenessStateSchema = z.enum(EXTERNAL_OBJECT_LIVENESS_STATES); +export const externalObjectMentionSourceKindSchema = z.enum(EXTERNAL_OBJECT_MENTION_SOURCE_KINDS); +export const externalObjectMentionConfidenceSchema = z.enum(EXTERNAL_OBJECT_MENTION_CONFIDENCES); +export const externalObjectProviderKeySchema = z.string().trim().min(1).max(80).regex(/^[a-z][a-z0-9_.-]*$/); +export const externalObjectTypeSchema = z.string().trim().min(1).max(80).regex(/^[a-z][a-z0-9_]*$/); + +export const externalObjectCanonicalIdentitySchema = z + .object({ + scheme: z.enum(["http", "https"]), + host: z.string().trim().min(1), + path: z.string().trim().min(1), + queryParamHashes: z.record(z.string().regex(/^[a-f0-9]{64}$/)).optional(), + }) + .strict(); + +export const externalObjectMentionSourceSchema = z + .object({ + sourceKind: externalObjectMentionSourceKindSchema, + documentKey: z.string().trim().min(1).optional().nullable(), + propertyKey: z.string().trim().min(1).optional().nullable(), + }) + .strict(); + +export type ExternalObjectCanonicalIdentityInput = z.infer; +export type ExternalObjectMentionSourceInput = z.infer; +export type ExternalObjectProviderKeyInput = z.infer; +export type ExternalObjectTypeInput = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 6323a7dc88..b04e1533d7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -59,6 +59,22 @@ export { updateResourceMembershipSchema, type UpdateResourceMembership, } from "./resource-memberships.js"; + +export { + externalObjectStatusCategorySchema, + externalObjectStatusToneSchema, + externalObjectLivenessStateSchema, + externalObjectMentionSourceKindSchema, + externalObjectMentionConfidenceSchema, + externalObjectProviderKeySchema, + externalObjectTypeSchema, + externalObjectCanonicalIdentitySchema, + externalObjectMentionSourceSchema, + type ExternalObjectCanonicalIdentityInput, + type ExternalObjectMentionSourceInput, + type ExternalObjectProviderKeyInput, + type ExternalObjectTypeInput, +} from "./external-object.js"; export { companySkillSourceTypeSchema, companySkillTrustLevelSchema, diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 66119debf6..986d0a3525 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -47,6 +47,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), + enableExternalObjects: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 45a1e3a845..3462d9d656 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -25,6 +25,7 @@ import { ISSUE_SURFACE_VISIBILITIES, } from "../constants.js"; import { routineVariableSchema } from "./routine.js"; +import { externalObjectProviderKeySchema, externalObjectTypeSchema } from "./external-object.js"; // --------------------------------------------------------------------------- // JSON Schema placeholder – a permissive validator for JSON Schema objects @@ -568,6 +569,55 @@ export const pluginApiRouteDeclarationSchema = z.object({ export type PluginApiRouteDeclarationInput = z.infer; +export const pluginObjectReferenceRefreshPolicySchema = z.object({ + defaultTtlSeconds: z.number().int().positive().max(86_400).optional(), + staleAfterSeconds: z.number().int().positive().max(604_800).optional(), +}).superRefine((value, ctx) => { + if ( + value.defaultTtlSeconds != null && + value.staleAfterSeconds != null && + value.staleAfterSeconds < value.defaultTtlSeconds + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "staleAfterSeconds must be greater than or equal to defaultTtlSeconds", + path: ["staleAfterSeconds"], + }); + } +}); + +export const pluginObjectReferenceProviderDeclarationSchema = z.object({ + providerKey: externalObjectProviderKeySchema, + displayName: z.string().min(1).max(100), + objectTypes: z.array(externalObjectTypeSchema).min(1), + urlPatterns: z.array(z.string().trim().min(1).max(500)).optional(), + refreshPolicy: pluginObjectReferenceRefreshPolicySchema.optional(), + webhookEndpointKeys: z.array(z.string().min(1)).optional(), +}).superRefine((value, ctx) => { + const duplicateObjectTypes = value.objectTypes.filter((type, i) => value.objectTypes.indexOf(type) !== i); + if (duplicateObjectTypes.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate objectTypes: ${[...new Set(duplicateObjectTypes)].join(", ")}`, + path: ["objectTypes"], + }); + } + + const webhookKeys = value.webhookEndpointKeys ?? []; + const duplicateWebhookKeys = webhookKeys.filter((key, i) => webhookKeys.indexOf(key) !== i); + if (duplicateWebhookKeys.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate webhookEndpointKeys: ${[...new Set(duplicateWebhookKeys)].join(", ")}`, + path: ["webhookEndpointKeys"], + }); + } +}); + +export type PluginObjectReferenceProviderDeclarationInput = z.infer< + typeof pluginObjectReferenceProviderDeclarationSchema +>; + // --------------------------------------------------------------------------- // Plugin Manifest V1 schema // --------------------------------------------------------------------------- @@ -647,6 +697,7 @@ export const pluginManifestV1Schema = z.object({ routines: z.array(pluginManagedRoutineDeclarationSchema).optional(), skills: z.array(pluginManagedSkillDeclarationSchema).optional(), localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(), + objectReferences: z.array(pluginObjectReferenceProviderDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(), @@ -787,6 +838,31 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.objectReferences && manifest.objectReferences.length > 0) { + for (const capability of ["external.objects.detect", "external.objects.read"] as const) { + if (!manifest.capabilities.includes(capability)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Capability '${capability}' is required when objectReferences are declared`, + path: ["capabilities"], + }); + } + } + + const declaredWebhookKeys = new Set((manifest.webhooks ?? []).map((webhook) => webhook.endpointKey)); + for (const [providerIndex, provider] of manifest.objectReferences.entries()) { + for (const endpointKey of provider.webhookEndpointKeys ?? []) { + if (!declaredWebhookKeys.has(endpointKey)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `objectReferences webhookEndpointKey "${endpointKey}" must match a declared webhook endpoint`, + path: ["objectReferences", providerIndex, "webhookEndpointKeys"], + }); + } + } + } + } + if (manifest.database) { const requiredCapabilities = [ "database.namespace.migrate", @@ -950,6 +1026,18 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.objectReferences) { + const providerKeys = manifest.objectReferences.map((provider) => provider.providerKey); + const duplicateProviders = providerKeys.filter((key, i) => providerKeys.indexOf(key) !== i); + if (duplicateProviders.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate object reference provider keys: ${[...new Set(duplicateProviders)].join(", ")}`, + path: ["objectReferences"], + }); + } + } + // UI slot ids must be unique within the plugin (namespaced at runtime) if (manifest.ui) { if (manifest.ui.slots) { diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index a80c01f779..0fe3a2625d 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -80,6 +80,84 @@ paperclipai_command_available() { return 1 } +existing_worktree_config_is_usable() { + WORKTREE_CONFIG_PATH="$worktree_config_path" \ + WORKTREE_ENV_PATH="$worktree_env_path" \ + node <<'EOF' +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function expandHomePrefix(value) { + if (!value) return value; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function parseEnvFile(contents) { + const entries = {}; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + const [, key, rawValue] = match; + const value = rawValue.trim(); + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + entries[key] = value.slice(1, -1); + continue; + } + entries[key] = value.replace(/\s+#.*$/, "").trim(); + } + return entries; +} + +function fail(reason) { + console.error(reason); + process.exit(1); +} + +const configPath = path.resolve(process.env.WORKTREE_CONFIG_PATH); +const envPath = path.resolve(process.env.WORKTREE_ENV_PATH); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +const env = parseEnvFile(fs.readFileSync(envPath, "utf8")); +const envConfigPath = expandHomePrefix(env.PAPERCLIP_CONFIG); +if (envConfigPath && path.resolve(envConfigPath) !== configPath) { + fail(`existing worktree env points at ${envConfigPath}, not ${configPath}`); +} + +const homeDir = expandHomePrefix(env.PAPERCLIP_HOME); +const instanceId = env.PAPERCLIP_INSTANCE_ID; +if (!homeDir || !instanceId) { + fail("existing worktree env is missing PAPERCLIP_HOME or PAPERCLIP_INSTANCE_ID"); +} +if (!fs.existsSync(homeDir)) { + fail(`existing worktree home does not exist on this host: ${homeDir}`); +} + +const instanceRoot = path.resolve(homeDir, "instances", instanceId); +const runtimePaths = [ + config.database?.embeddedPostgresDataDir, + config.database?.backup?.dir, + config.logging?.logDir, + config.storage?.localDisk?.baseDir, + config.secrets?.localEncrypted?.keyFilePath, +].filter((value) => typeof value === "string" && value.length > 0); + +for (const rawValue of runtimePaths) { + const resolved = path.resolve(expandHomePrefix(rawValue)); + const relative = path.relative(instanceRoot, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + fail(`existing worktree config path is outside ${instanceRoot}: ${resolved}`); + } +} +EOF +} + write_fallback_worktree_config() { WORKTREE_NAME="$worktree_name" \ BASE_CWD="$base_cwd" \ @@ -332,9 +410,12 @@ main().catch((error) => { EOF } -if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]]; then +if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2 else + if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then + echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2 + fi if paperclipai_command_available; then run_isolated_worktree_init else diff --git a/server/src/__tests__/environment-live-ssh.test.ts b/server/src/__tests__/environment-live-ssh.test.ts index 303d7ff7d9..cf831b04e5 100644 --- a/server/src/__tests__/environment-live-ssh.test.ts +++ b/server/src/__tests__/environment-live-ssh.test.ts @@ -180,5 +180,5 @@ describeLiveSsh("live SSH environment smoke", () => { expect(result.stdout).toContain(config.remoteWorkspacePath); expect(result.stdout).toContain("git"); expect(result.stdout).toContain("tar"); - }); + }, 30_000); }); diff --git a/server/src/__tests__/external-object-routes.test.ts b/server/src/__tests__/external-object-routes.test.ts new file mode 100644 index 0000000000..24e8064336 --- /dev/null +++ b/server/src/__tests__/external-object-routes.test.ts @@ -0,0 +1,252 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const issueId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const ownerAgentId = "33333333-3333-4333-8333-333333333333"; +const peerAgentId = "44444444-4444-4444-8444-444444444444"; +const ownerRunId = "55555555-5555-4555-8555-555555555555"; + +const mockIssueService = vi.hoisted(() => ({ + assertCheckoutOwner: vi.fn(), + getById: vi.fn(), +})); + +const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(), + hasPermission: vi.fn(), +})); + +const mockAgentService = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockExternalObjectsService = vi.hoisted(() => ({ + getIssueSummary: vi.fn(), + getIssueSummaries: vi.fn(), + listForIssue: vi.fn(), + refreshIssueObjects: vi.fn(), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +function registerRouteMocks() { + vi.doMock("../services/external-objects.js", () => ({ + externalObjectService: () => mockExternalObjectsService, + })); + + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + + vi.doMock("../services/task-watchdog-scope.js", () => ({ + TASK_WATCHDOG_ORIGIN_KIND: "task_watchdog", + resolveTaskWatchdogMutationScope: vi.fn(async () => ({ kind: "none" })), + taskWatchdogScopeAllowsIssueMutation: vi.fn(async () => ({ kind: "none" })), + })); + + vi.doMock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + companyService: () => ({ + getById: vi.fn(async () => null), + }), + companySearchService: () => ({}), + documentAnnotationService: () => ({}), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({}), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), + }), + issueApprovalService: () => ({}), + issueRecoveryActionService: () => ({}), + issueReferenceService: () => ({ + listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), + }), + issueService: () => mockIssueService, + issueThreadInteractionService: () => ({}), + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({}), + workProductService: () => ({}), + })); +} + +function makeIssue(overrides: Record = {}) { + return { + id: issueId, + companyId, + status: "in_progress", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: ownerAgentId, + assigneeUserId: null, + identifier: "PAP-2265", + title: "External object routes", + executionWorkspaceId: null, + ...overrides, + }; +} + +async function createApp(actor: Express.Request["actor"]) { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", issueRoutes({} as any, { provider: "local_disk" } as any)); + app.use(errorHandler); + return app; +} + +function boardActor(): Express.Request["actor"] { + return { + type: "board", + userId: "board-user", + userName: null, + userEmail: null, + companyIds: [companyId], + memberships: [], + isInstanceAdmin: false, + source: "local_implicit", + }; +} + +function ownerActor(): Express.Request["actor"] { + return { + type: "agent", + agentId: ownerAgentId, + companyId, + keyId: "key-1", + runId: ownerRunId, + source: "agent_key", + }; +} + +function peerActor(): Express.Request["actor"] { + return { + type: "agent", + agentId: peerAgentId, + companyId, + keyId: "key-2", + runId: "66666666-6666-4666-8666-666666666666", + source: "agent_key", + }; +} + +describe("external object routes", () => { + beforeEach(() => { + vi.resetModules(); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../services/external-objects.js"); + registerRouteMocks(); + vi.resetAllMocks(); + mockIssueService.getById.mockResolvedValue(makeIssue()); + mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); + mockAccessService.hasPermission.mockResolvedValue(false); + mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({ + allowed: action === "issue:mutate", + explanation: "Denied by test mock", + })); + mockAgentService.list.mockResolvedValue([ + { id: ownerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } }, + { id: peerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } }, + ]); + mockExternalObjectsService.getIssueSummary.mockResolvedValue({ total: 1, objects: [] }); + mockExternalObjectsService.getIssueSummaries.mockResolvedValue(new Map([ + [issueId, { total: 1, objects: [] }], + ])); + mockExternalObjectsService.listForIssue.mockResolvedValue([]); + mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([ + { object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" }, + ]); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enableExternalObjects: true, + }); + }); + + it("enforces company access on read routes", async () => { + const app = await createApp({ ...ownerActor(), companyId: "other-company" }); + + const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`); + + expect(res.status).toBe(403); + expect(mockExternalObjectsService.getIssueSummary).not.toHaveBeenCalled(); + }); + + it("allows board users to read issue external object summaries", async () => { + const app = await createApp(boardActor()); + + const res = await request(app).get(`/api/issues/${issueId}/external-object-summary`); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(1); + expect(mockExternalObjectsService.getIssueSummary).toHaveBeenCalledWith(issueId); + }); + + it("allows board users to fetch company-scoped external object summaries in bulk", async () => { + const app = await createApp(boardActor()); + + const res = await request(app) + .post(`/api/companies/${companyId}/issues/external-object-summaries`) + .send({ issueIds: [issueId] }); + + expect(res.status).toBe(200); + expect(res.body.summaries[issueId].total).toBe(1); + expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, [issueId]); + }); + + it("enforces company access on bulk external object summaries", async () => { + const app = await createApp({ ...ownerActor(), companyId: "other-company" }); + + const res = await request(app) + .post(`/api/companies/${companyId}/issues/external-object-summaries`) + .send({ issueIds: [issueId] }); + + expect(res.status).toBe(403); + expect(mockExternalObjectsService.getIssueSummaries).not.toHaveBeenCalled(); + }); + + it("requires active checkout ownership for agent manual refresh", async () => { + const app = await createApp(peerActor()); + + const res = await request(app) + .post(`/api/issues/${issueId}/external-objects/refresh`) + .send({}); + + expect(res.status).toBe(409); + expect(res.body.error).toBe("Issue is checked out by another agent"); + expect(mockExternalObjectsService.refreshIssueObjects).not.toHaveBeenCalled(); + }); + + it("allows the checked-out agent to request manual refresh", async () => { + const app = await createApp(ownerActor()); + + const res = await request(app) + .post(`/api/issues/${issueId}/external-objects/refresh`) + .send({}); + + expect(res.status).toBe(200); + expect(mockIssueService.assertCheckoutOwner).toHaveBeenCalledWith(issueId, ownerAgentId, ownerRunId); + expect(mockExternalObjectsService.refreshIssueObjects).toHaveBeenCalledWith(issueId, expect.objectContaining({ + companyId, + actor: expect.objectContaining({ actorType: "agent", actorId: ownerAgentId }), + })); + }); +}); diff --git a/server/src/__tests__/external-objects-service.test.ts b/server/src/__tests__/external-objects-service.test.ts new file mode 100644 index 0000000000..967ea8ae42 --- /dev/null +++ b/server/src/__tests__/external-objects-service.test.ts @@ -0,0 +1,705 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { + companies, + createDb, + activityLog, + externalObjectMentions, + externalObjects, + issueComments, + issues, + plugins, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + createExternalObjectDetectorRegistry, + createExternalObjectResolverRegistry, + externalObjectService, + type ExternalObjectResolver, +} from "../services/external-objects.js"; +import { canonicalizeExternalObjectUrl } from "@paperclipai/shared/external-objects-server"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; +import { createGitHubExternalObjectProvider } from "../services/github-external-object-provider.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres external object tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describe("external object registries", () => { + it("lets provider detectors claim urls before the generic fallback", async () => { + const canonical = canonicalizeExternalObjectUrl("https://github.com/acme/app/pull/42"); + if (!canonical) throw new Error("expected canonical url"); + const registry = createExternalObjectDetectorRegistry([ + { + key: "github", + detect: ({ urls }) => + urls.map((url) => ({ + canonical: url, + detectorKey: "github", + providerKey: "github", + objectType: "pull_request", + externalId: "acme/app#42", + confidence: "exact", + })), + }, + ]); + + const detections = await registry.detect({ + companyId: "company-1", + urls: [canonical], + sourceContext: { + companyId: "company-1", + sourceIssueId: "issue-1", + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + propertyKey: null, + }, + }); + + expect(detections).toHaveLength(1); + expect(detections[0]).toMatchObject({ + providerKey: "github", + objectType: "pull_request", + externalId: "acme/app#42", + }); + }); + + it("falls back to generic url objects when no provider detector claims a url", async () => { + const canonical = canonicalizeExternalObjectUrl("https://example.com/path?token=secret#frag"); + if (!canonical) throw new Error("expected canonical url"); + const registry = createExternalObjectDetectorRegistry([]); + + const detections = await registry.detect({ + companyId: "company-1", + urls: [canonical], + sourceContext: { + companyId: "company-1", + sourceIssueId: "issue-1", + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + propertyKey: null, + }, + }); + + expect(detections[0]).toMatchObject({ + providerKey: "url", + objectType: "link", + externalId: canonical.canonicalIdentityHash, + displayTitle: "https://example.com/path", + }); + }); + + it("matches resolvers by provider and optional object type", () => { + const fallbackResolver: ExternalObjectResolver = { + providerKey: "github", + resolve: async () => ({ + ok: true, + snapshot: { statusCategory: "unknown", statusTone: "neutral" }, + }), + }; + const pullRequestResolver: ExternalObjectResolver = { + providerKey: "github", + objectType: "pull_request", + resolve: async () => ({ + ok: true, + snapshot: { statusCategory: "open", statusTone: "info" }, + }), + }; + const registry = createExternalObjectResolverRegistry([pullRequestResolver, fallbackResolver]); + + expect(registry.find({ providerKey: "github", objectType: "pull_request" })).toBe(pullRequestResolver); + expect(registry.find({ providerKey: "github", objectType: "issue" })).toBe(fallbackResolver); + expect(registry.find({ providerKey: "linear", objectType: "issue" })).toBeNull(); + }); +}); + +describe("GitHub external object provider", () => { + function githubObject(path: string, objectType: "pull_request" | "issue") { + const canonical = canonicalizeExternalObjectUrl(`https://github.com/acme/app/${path}`); + if (!canonical) throw new Error("expected canonical url"); + return { + id: randomUUID(), + companyId: "company-1", + providerKey: "github", + objectType, + externalId: `acme/app#${path}`, + sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl, + canonicalIdentityHash: canonical.canonicalIdentityHash, + displayTitle: "acme/app#42", + statusKey: null, + statusLabel: null, + statusCategory: "unknown", + statusTone: "neutral", + liveness: "unknown", + isTerminal: false, + data: {}, + remoteVersion: null, + etag: null, + } as any; + } + + function response(body: Record, init: ResponseInit = {}) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json", etag: '"etag-1"', ...(init.headers ?? {}) }, + ...init, + }); + } + + it("detects GitHub pull request and issue URLs before the generic fallback", async () => { + const provider = createGitHubExternalObjectProvider({} as any, { tokenProvider: null }); + const pr = canonicalizeExternalObjectUrl("https://github.com/Acme/App/pull/42?token=secret#discussion"); + const issue = canonicalizeExternalObjectUrl("https://github.com/Acme/App/issues/7"); + const other = canonicalizeExternalObjectUrl("https://example.com/Acme/App/pull/42"); + if (!pr || !issue || !other) throw new Error("expected canonical urls"); + + const detections = await provider.detector.detect({ + companyId: "company-1", + urls: [pr, issue, other], + sourceContext: { + companyId: "company-1", + sourceIssueId: "issue-1", + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + propertyKey: null, + }, + }); + + expect(detections).toEqual([ + expect.objectContaining({ + providerKey: "github", + objectType: "pull_request", + externalId: "acme/app#pull/42", + displayKey: "GitHub Pull Request", + iconKey: "github", + displayTitle: "Acme/App#42", + }), + expect.objectContaining({ + providerKey: "github", + objectType: "issue", + externalId: "acme/app#issues/7", + displayKey: "GitHub Issue", + iconKey: "github", + displayTitle: "Acme/App#7", + }), + ]); + expect(JSON.stringify(detections)).not.toContain("secret"); + }); + + it.each([ + [ + "open", + { state: "open", draft: false, merged: false, title: "Ship it", updated_at: "2026-04-24T01:02:03Z" }, + { statusKey: "open", statusLabel: "Open", statusIconKey: "git-pull-request", statusCategory: "open", statusTone: "info", isTerminal: false }, + ], + [ + "draft", + { state: "open", draft: true, merged: false, title: "WIP", updated_at: "2026-04-24T01:02:03Z" }, + { statusKey: "draft", statusLabel: "Draft", statusIconKey: "clock", statusCategory: "waiting", statusTone: "warning", isTerminal: false }, + ], + [ + "closed", + { state: "closed", draft: false, merged: false, title: "Closed PR", updated_at: "2026-04-24T01:02:03Z" }, + { statusKey: "closed", statusLabel: "Closed", statusIconKey: "x-circle", statusCategory: "closed", statusTone: "muted", isTerminal: true }, + ], + [ + "merged", + { state: "closed", draft: false, merged: true, title: "Merged PR", updated_at: "2026-04-24T01:02:03Z" }, + { statusKey: "merged", statusLabel: "Merged", statusIconKey: "git-merge", statusCategory: "succeeded", statusTone: "success", isTerminal: true }, + ], + ])("resolves a %s pull request snapshot", async (_name, body, expected) => { + const fetch = vi.fn(async () => response(body)); + const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null }); + const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!; + + const result = await resolver.resolve({ + companyId: "company-1", + object: githubObject("pull/42", "pull_request"), + }); + + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/acme/app/pulls/42", + expect.objectContaining({ + headers: expect.not.objectContaining({ authorization: expect.any(String) }), + }), + ); + expect(result).toEqual({ + ok: true, + snapshot: expect.objectContaining({ + ...expected, + displayKey: "GitHub Pull Request", + iconKey: "github", + displayTitle: expect.stringContaining(String(body.title)), + remoteVersion: "2026-04-24T01:02:03Z", + etag: '"etag-1"', + data: expect.objectContaining({ + provider: "github", + owner: "acme", + repo: "app", + number: 42, + }), + }), + }); + expect(JSON.stringify(result)).not.toContain("authorization"); + }); + + it.each([ + [ + "open", + { state: "open", title: "Issue", state_reason: null, updated_at: "2026-04-24T01:02:03Z" }, + { statusKey: "open", statusLabel: "Open", statusIconKey: "circle-dot", statusCategory: "open", statusTone: "info", isTerminal: false }, + ], + [ + "closed", + { state: "closed", title: "Issue", state_reason: "completed", updated_at: "2026-04-24T01:02:03Z" }, + { statusKey: "closed_completed", statusLabel: "Closed: completed", statusIconKey: "circle", statusCategory: "closed", statusTone: "muted", isTerminal: true }, + ], + ])("resolves a %s issue snapshot", async (_name, body, expected) => { + const fetch = vi.fn(async () => response(body)); + const provider = createGitHubExternalObjectProvider({} as any, { fetch, tokenProvider: null }); + const resolver = provider.resolvers.find((entry) => entry.objectType === "issue")!; + + const result = await resolver.resolve({ + companyId: "company-1", + object: githubObject("issues/42", "issue"), + }); + + expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/acme/app/issues/42", expect.any(Object)); + expect(result).toEqual({ + ok: true, + snapshot: expect.objectContaining({ + ...expected, + displayKey: "GitHub Issue", + iconKey: "github", + data: expect.objectContaining({ + provider: "github", + owner: "acme", + repo: "app", + number: 42, + }), + }), + }); + }); + + it("uses a configured token without storing it in the resolved snapshot", async () => { + const fetch = vi.fn(async (_url: string, init?: RequestInit) => { + expect(init?.headers).toEqual(expect.objectContaining({ authorization: "Bearer ghp_secret" })); + return response({ state: "open", draft: false, merged: false, title: "Private PR" }); + }); + const provider = createGitHubExternalObjectProvider({} as any, { + fetch, + tokenProvider: async () => "ghp_secret", + }); + const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!; + + const result = await resolver.resolve({ + companyId: "company-1", + object: githubObject("pull/42", "pull_request"), + }); + + expect(result.ok).toBe(true); + expect(JSON.stringify(result)).not.toContain("ghp_secret"); + }); + + it.each([ + [ + "auth-required", + new Response("", { status: 401 }), + { ok: false, liveness: "auth_required", errorCode: "github_auth_required" }, + ], + [ + "rate-limit", + new Response("", { + status: 403, + headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 120) }, + }), + { ok: false, liveness: "unreachable", errorCode: "github_rate_limited" }, + ], + [ + "not-found", + new Response("", { status: 404, headers: { etag: '"missing"' } }), + { ok: true, snapshot: expect.objectContaining({ displayKey: "GitHub Pull Request", iconKey: "github", statusKey: "not_found", statusIconKey: "archive", statusCategory: "archived", statusTone: "muted" }) }, + ], + ])("maps %s responses to provider-safe results", async (_name, githubResponse, expected) => { + const provider = createGitHubExternalObjectProvider({} as any, { + fetch: async () => githubResponse, + tokenProvider: null, + }); + const resolver = provider.resolvers.find((entry) => entry.objectType === "pull_request")!; + + const result = await resolver.resolve({ + companyId: "company-1", + object: githubObject("pull/42", "pull_request"), + }); + + expect(result).toEqual(expect.objectContaining(expected)); + expect(JSON.stringify(result)).not.toContain("http"); + }); +}); + +describeEmbeddedPostgres("externalObjectService", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-external-objects-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(externalObjectMentions); + await db.delete(externalObjects); + await db.delete(issueComments); + await db.delete(issues); + await db.delete(plugins); + await db.delete(companies); + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createIssue(companyId = randomUUID()) { + const issueId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `E${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + identifier: `PAP-${companyId.replace(/-/g, "").slice(0, 12).toUpperCase()}`, + title: "External refs", + description: "Track https://github.com/acme/app/pull/42?token=secret#discussion twice https://github.com/acme/app/pull/42.", + status: "todo", + priority: "medium", + }); + return { companyId, issueId }; + } + + it("syncs sanitized, deduped mentions without storing secret-bearing urls", async () => { + const { companyId, issueId } = await createIssue(); + const svc = externalObjectService(db); + + await svc.syncIssue(issueId); + + const [objectRows, mentionRows] = await Promise.all([ + db.select().from(externalObjects), + db.select().from(externalObjectMentions), + ]); + expect(objectRows).toHaveLength(1); + expect(objectRows[0]).toMatchObject({ + companyId, + providerKey: "github", + objectType: "pull_request", + externalId: "acme/app#pull/42", + sanitizedCanonicalUrl: "https://github.com/acme/app/pull/42", + liveness: "unknown", + statusCategory: "unknown", + }); + expect(JSON.stringify(objectRows[0])).not.toContain("secret"); + expect(mentionRows).toHaveLength(1); + expect(mentionRows[0]).toMatchObject({ + companyId, + sourceIssueId: issueId, + sourceKind: "description", + sanitizedDisplayUrl: "https://github.com/acme/app/pull/42", + matchedTextRedacted: "https://github.com/acme/app/pull/42", + }); + }); + + it("no-ops detection and summaries when external objects are disabled", async () => { + const { issueId } = await createIssue(); + const svc = externalObjectService(db, { enabled: false }); + + await svc.syncIssue(issueId); + + const [objectRows, mentionRows, summary] = await Promise.all([ + db.select().from(externalObjects), + db.select().from(externalObjectMentions), + svc.getIssueSummary(issueId), + ]); + expect(objectRows).toHaveLength(0); + expect(mentionRows).toHaveLength(0); + expect(summary).toMatchObject({ + total: 0, + byStatusCategory: {}, + byLiveness: {}, + highestSeverity: "neutral", + objects: [], + }); + }); + + it("preserves last-known status when resolver reports auth and unreachable failures", async () => { + const { companyId, issueId } = await createIssue(); + const resolver: ExternalObjectResolver = { + providerKey: "url", + objectType: "link", + resolve: vi + .fn() + .mockResolvedValueOnce({ + ok: true, + snapshot: { + statusCategory: "open", + statusTone: "info", + statusKey: "open", + statusLabel: "Open", + ttlSeconds: 1, + }, + }) + .mockResolvedValueOnce({ + ok: false, + liveness: "auth_required", + errorCode: "auth_required", + errorMessage: "token=secret failed for https://github.com/acme/app/pull/42?token=secret", + retryAfterSeconds: 60, + }) + .mockResolvedValueOnce({ + ok: false, + liveness: "unreachable", + errorCode: "network", + errorMessage: "GET https://github.com/acme/app/pull/42 failed", + retryAfterSeconds: 60, + }), + }; + const svc = externalObjectService(db, { resolvers: [resolver], github: false }); + await svc.syncIssue(issueId); + const object = await db.select().from(externalObjects).then((rows) => rows[0]!); + + await svc.refreshObject(object.id, { companyId, force: true }); + await svc.refreshObject(object.id, { companyId, force: true }); + + const authFailure = await db.select().from(externalObjects).then((rows) => rows[0]!); + expect(authFailure.lastErrorMessage).toContain("token=[redacted]"); + expect(authFailure.lastErrorMessage).not.toContain("secret"); + + await svc.refreshObject(object.id, { companyId, force: true }); + + const updated = await db.select().from(externalObjects).then((rows) => rows[0]!); + expect(updated.statusCategory).toBe("open"); + expect(updated.statusLabel).toBe("Open"); + expect(updated.liveness).toBe("unreachable"); + expect(updated.lastErrorMessage).toContain("[redacted-url]"); + expect(updated.lastErrorMessage).not.toContain("secret"); + }); + + it("schedules newly detected objects for automatic refresh", async () => { + const { companyId, issueId } = await createIssue(); + const resolve = vi.fn(async () => ({ + ok: true as const, + snapshot: { + statusCategory: "open" as const, + statusTone: "info" as const, + statusKey: "open", + statusLabel: "Open", + ttlSeconds: 300, + }, + })); + const resolver: ExternalObjectResolver = { + providerKey: "url", + objectType: "link", + resolve, + }; + const svc = externalObjectService(db, { resolvers: [resolver], github: false }); + await svc.syncIssue(issueId); + const object = await db.select().from(externalObjects).then((rows) => rows[0]!); + + expect(object.nextRefreshAt).toBeInstanceOf(Date); + + const refreshed = await svc.refreshDueObjects(companyId, 50, new Date(Date.now() + 1_000)); + + expect(refreshed).toHaveLength(1); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it("removes comment mentions when a synced comment is hard-deleted", async () => { + const { companyId, issueId } = await createIssue(); + const commentId = randomUUID(); + await db.insert(issueComments).values({ + id: commentId, + companyId, + issueId, + authorType: "user", + authorUserId: "local-board", + body: "See https://github.com/acme/app/issues/88", + }); + const svc = externalObjectService(db, { github: false }); + + await svc.syncComment(commentId); + expect(await db.select().from(externalObjectMentions)).toHaveLength(1); + + await db.delete(issueComments).where(eq(issueComments.id, commentId)); + await svc.syncComment(commentId); + + expect(await db.select().from(externalObjectMentions)).toHaveLength(0); + }); + + it("skips terminal objects when refreshing due objects", async () => { + const { companyId, issueId } = await createIssue(); + const resolve = vi.fn(async () => ({ + ok: true as const, + snapshot: { + statusCategory: "closed" as const, + statusTone: "muted" as const, + statusKey: "closed", + statusLabel: "Closed", + isTerminal: true, + ttlSeconds: 1, + }, + })); + const resolver: ExternalObjectResolver = { + providerKey: "url", + objectType: "link", + resolve, + }; + const svc = externalObjectService(db, { resolvers: [resolver], github: false }); + await svc.syncIssue(issueId); + const object = await db.select().from(externalObjects).then((rows) => rows[0]!); + + await svc.refreshObject(object.id, { companyId, force: true }); + await db + .update(externalObjects) + .set({ nextRefreshAt: new Date(0) }) + .where(eq(externalObjects.id, object.id)); + + const refreshed = await svc.refreshDueObjects(companyId); + + expect(refreshed).toEqual([]); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it("keeps external object identities company-scoped for duplicate urls", async () => { + const first = await createIssue(); + const second = await createIssue(); + const svc = externalObjectService(db); + + await svc.syncIssue(first.issueId); + await svc.syncIssue(second.issueId); + + const objectRows = await db.select().from(externalObjects); + expect(objectRows).toHaveLength(2); + expect(new Set(objectRows.map((row) => row.companyId))).toEqual(new Set([first.companyId, second.companyId])); + expect(new Set(objectRows.map((row) => row.canonicalIdentityHash)).size).toBe(1); + }); + + it("uses a mock plugin provider to detect and resolve non-GitHub objects", async () => { + const { companyId, issueId } = await createIssue(); + await db + .update(issues) + .set({ + description: "Track https://mock.example/tickets/123?secret=drop", + }) + .where(eq(issues.id, issueId)); + + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.mock-object-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "Mock Object Provider", + description: "Detects mock tracker tickets", + author: "Paperclip", + categories: ["connector"], + capabilities: ["external.objects.detect", "external.objects.read"], + entrypoints: { worker: "dist/worker.js" }, + objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + }, + ], + }; + const [plugin] = await db.insert(plugins).values({ + pluginKey: manifest.id, + packageName: "@paperclip/mock-object-provider", + version: manifest.version, + apiVersion: 1, + categories: manifest.categories, + manifestJson: manifest, + status: "ready", + installOrder: 1, + }).returning(); + + const workerManager = { + call: vi.fn(async (pluginId: string, method: string, params: any) => { + expect(pluginId).toBe(plugin!.id); + if (method === "detectExternalObjects") { + return { + detections: params.urls.map((url: any) => ({ + urlIdentityHash: url.canonicalIdentityHash, + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + displayKey: "Mock Ticket", + iconKey: "circle-dot", + displayTitle: "Mock ticket 123", + confidence: "exact", + })), + }; + } + if (method === "resolveExternalObject") { + return { + ok: true, + snapshot: { + displayKey: "Mock Ticket", + iconKey: "circle-dot", + displayTitle: `Resolved ${params.externalId}`, + statusKey: "ready", + statusLabel: "Ready", + statusIconKey: "check-circle", + statusCategory: "succeeded", + statusTone: "success", + ttlSeconds: 300, + }, + }; + } + throw new Error(`unexpected method ${method}`); + }), + } as unknown as PluginWorkerManager; + + const svc = externalObjectService(db, { pluginWorkerManager: workerManager }); + await svc.syncIssue(issueId); + + const object = await db.select().from(externalObjects).then((rows) => rows[0]!); + expect(object).toMatchObject({ + companyId, + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + displayKey: "Mock Ticket", + iconKey: "circle-dot", + pluginId: plugin!.id, + sanitizedCanonicalUrl: "https://mock.example/tickets/123", + }); + expect(JSON.stringify(object)).not.toContain("secret"); + + const refreshed = await svc.refreshObject(object.id, { companyId, force: true }); + expect(refreshed.object).toMatchObject({ + displayKey: "Mock Ticket", + iconKey: "circle-dot", + displayTitle: "Resolved MOCK-123", + statusIconKey: "check-circle", + statusCategory: "succeeded", + statusTone: "success", + liveness: "fresh", + }); + }); +}); diff --git a/server/src/__tests__/feedback-service.test.ts b/server/src/__tests__/feedback-service.test.ts index 8a979d84c5..bde8659b03 100644 --- a/server/src/__tests__/feedback-service.test.ts +++ b/server/src/__tests__/feedback-service.test.ts @@ -22,13 +22,25 @@ import { issues, } from "@paperclipai/db"; import { feedbackService } from "../services/feedback.ts"; -import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres feedback service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} async function closeDbClient(db: ReturnType | undefined) { await db?.$client?.end?.({ timeout: 0 }); } -describe("feedbackService.saveIssueVote", () => { +describeEmbeddedPostgres("feedbackService.saveIssueVote", () => { let db!: ReturnType; let svc!: ReturnType; let tempDb: Awaited> | null = null; diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index c0edc51ce8..a883a37009 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -15,7 +15,19 @@ import { import { runningProcesses } from "../adapters/index.js"; import { heartbeatService } from "../services/heartbeat.ts"; import { SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY } from "../services/recovery/index.ts"; -import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat comment wake batching tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} async function waitFor(condition: () => boolean | Promise, timeoutMs = 10_000, intervalMs = 50) { const startedAt = Date.now(); @@ -147,7 +159,7 @@ async function createControlledGatewayServer() { }; } -describe("heartbeat comment wake batching", () => { +describeEmbeddedPostgres("heartbeat comment wake batching", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index bd6b63a9c4..3965dacb97 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -100,6 +100,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: false, enableTaskWatchdogs: false, enableCloudSync: false, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -142,6 +143,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -194,6 +196,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: false, enableTaskWatchdogs: false, enableCloudSync: false, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -271,6 +274,24 @@ describe("instance settings routes", () => { ).toBe(true); }); + it("allows local board users to update external object detection", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableExternalObjects: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableExternalObjects: true, + }); + }); + it("allows local board users to update issue graph liveness auto-recovery", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 30b595e59a..4ef90da5fe 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -19,6 +19,7 @@ describe("instance settings service", () => { enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: true, enableConferenceRoomChat: false, + enableExternalObjects: false, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableTaskWatchdogs: true, diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 436915de57..f2aabc93e4 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -104,6 +104,34 @@ const mockHeartbeatService = vi.hoisted(() => ({ getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), })); +const mockExternalObjectService = vi.hoisted(() => ({ + getIssueSummaries: vi.fn(async () => new Map()), + getIssueSummary: vi.fn(async () => ({ + authRequiredCount: 0, + byLiveness: {}, + byStatusCategory: {}, + highestSeverity: "muted", + objects: [], + staleCount: 0, + total: 0, + unreachableCount: 0, + })), + getProjectSummary: vi.fn(async () => ({ + authRequiredCount: 0, + byLiveness: {}, + byStatusCategory: {}, + highestSeverity: "muted", + objects: [], + staleCount: 0, + total: 0, + unreachableCount: 0, + })), + listForIssue: vi.fn(async () => []), + refreshIssueObjects: vi.fn(async () => []), + syncCommentSafely: vi.fn(async () => undefined), + syncDocumentSafely: vi.fn(async () => undefined), + syncIssueSafely: vi.fn(async () => undefined), +})); function registerRouteMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ @@ -136,6 +164,10 @@ function registerRouteMocks() { workProductService: () => mockWorkProductService, })); + vi.doMock("../services/external-objects.js", () => ({ + externalObjectService: () => mockExternalObjectService, + })); + vi.doMock("../services/activity-log.js", () => ({ logActivity: vi.fn(async () => undefined), })); @@ -329,6 +361,7 @@ describe("agent issue mutation checkout ownership", () => { vi.doUnmock("../services/activity-log.js"); vi.doUnmock("../services/agents.js"); vi.doUnmock("../services/documents.js"); + vi.doUnmock("../services/external-objects.js"); vi.doUnmock("../services/index.js"); vi.doUnmock("../services/issues.js"); vi.doUnmock("../services/work-products.js"); @@ -464,6 +497,14 @@ describe("agent issue mutation checkout ownership", () => { mockIssueService.findMentionedAgents.mockReset(); mockDocumentService.upsertIssueDocument.mockReset(); mockWorkProductService.createForIssue.mockReset(); + mockExternalObjectService.getIssueSummaries.mockClear(); + mockExternalObjectService.getIssueSummary.mockClear(); + mockExternalObjectService.getProjectSummary.mockClear(); + mockExternalObjectService.listForIssue.mockClear(); + mockExternalObjectService.refreshIssueObjects.mockClear(); + mockExternalObjectService.syncCommentSafely.mockClear(); + mockExternalObjectService.syncDocumentSafely.mockClear(); + mockExternalObjectService.syncIssueSafely.mockClear(); mockWorkProductService.getById.mockReset(); mockWorkProductService.remove.mockReset(); mockWorkProductService.update.mockReset(); diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index 8c841f08d1..bd46a12ce3 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -57,6 +57,24 @@ const mockIssueReferenceService = vi.hoisted(() => ({ syncDocument: vi.fn(async () => undefined), syncIssue: vi.fn(async () => undefined), })); +const mockExternalObjectService = vi.hoisted(() => ({ + getIssueSummaries: vi.fn(async () => ({ summaries: {} })), + getIssueSummary: vi.fn(async () => ({ + authRequiredCount: 0, + byLiveness: {}, + byStatusCategory: {}, + highestSeverity: "muted", + objects: [], + staleCount: 0, + total: 0, + unreachableCount: 0, + })), + listForIssue: vi.fn(async () => []), + refreshIssueObjects: vi.fn(async () => []), + syncCommentSafely: vi.fn(async () => undefined), + syncDocumentSafely: vi.fn(async () => undefined), + syncIssueSafely: vi.fn(async () => undefined), +})); function registerModuleMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ @@ -92,6 +110,10 @@ function registerModuleMocks() { issueService: () => mockIssueService, })); + vi.doMock("../services/external-objects.js", () => ({ + externalObjectService: () => mockExternalObjectService, + })); + vi.doMock("../services/index.js", () => ({ companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), @@ -181,6 +203,7 @@ describe.sequential("issue comment cancel routes", () => { vi.doUnmock("../telemetry.js"); vi.doUnmock("../services/access.js"); vi.doUnmock("../services/activity-log.js"); + vi.doUnmock("../services/external-objects.js"); vi.doUnmock("../services/feedback.js"); vi.doUnmock("../services/heartbeat.js"); vi.doUnmock("../services/index.js"); @@ -240,6 +263,7 @@ describe.sequential("issue comment cancel routes", () => { }); mockIssueReferenceService.deleteCommentSource.mockResolvedValue(undefined); mockIssueReferenceService.syncComment.mockResolvedValue(undefined); + mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined); }); it("cancels a queued comment from its author and restores the deleted body", async () => { @@ -352,6 +376,7 @@ describe.sequential("issue comment cancel routes", () => { expect.objectContaining({ afterTombstone: expect.any(Function) }), ); expect(mockIssueReferenceService.syncComment).toHaveBeenCalledWith("comment-1", "tx"); + expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("comment-1", "tx"); expect(mockDocumentAnnotationService.cleanupForIssueCommentDeletion).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", "comment-1", @@ -362,6 +387,7 @@ describe.sequential("issue comment cancel routes", () => { "tx", ); expect(mockIssueReferenceService.deleteCommentSource).toHaveBeenCalledWith("annotation-comment-1", "tx"); + expect(mockExternalObjectService.syncCommentSafely).toHaveBeenCalledWith("annotation-comment-1", "tx"); const deletedActivity = mockLogActivity.mock.calls.find((call) => call[1]?.action === "issue.comment_deleted")?.[1]; expect(deletedActivity).toEqual(expect.objectContaining({ action: "issue.comment_deleted", diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 3069367426..22ef7d7fa7 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -79,6 +79,10 @@ const mockIssueRecoveryActionService = vi.hoisted(() => ({ const mockIssueTreeControlService = vi.hoisted(() => ({ getActivePauseHoldGate: vi.fn(async () => null), })); +const mockExternalObjectService = vi.hoisted(() => ({ + syncCommentSafely: vi.fn(async () => undefined), + syncIssueSafely: vi.fn(async () => undefined), +})); vi.mock("@paperclipai/shared/telemetry", () => ({ trackAgentTaskCompleted: vi.fn(), @@ -158,6 +162,10 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); +vi.mock("../services/external-objects.js", () => ({ + externalObjectService: () => mockExternalObjectService, +})); + function createApp() { const app = express(); app.use(express.json()); @@ -253,6 +261,8 @@ describe.sequential("issue comment reopen routes", () => { mockRoutineService.syncRunStatusForIssue.mockReset(); mockIssueRecoveryActionService.getActiveForIssue.mockReset(); mockIssueTreeControlService.getActivePauseHoldGate.mockReset(); + mockExternalObjectService.syncCommentSafely.mockReset(); + mockExternalObjectService.syncIssueSafely.mockReset(); mockTxInsertValues.mockReset(); mockTxInsert.mockReset(); mockDbSelect.mockReset(); @@ -276,6 +286,8 @@ describe.sequential("issue comment reopen routes", () => { mockHeartbeatService.getRun.mockResolvedValue(null); mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockExternalObjectService.syncCommentSafely.mockResolvedValue(undefined); + mockExternalObjectService.syncIssueSafely.mockResolvedValue(undefined); mockLogActivity.mockResolvedValue(undefined); mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); mockFeedbackService.saveIssueVote.mockResolvedValue({ diff --git a/server/src/__tests__/plugin-environment-driver-seam.test.ts b/server/src/__tests__/plugin-environment-driver-seam.test.ts index ec89adc75e..013994099c 100644 --- a/server/src/__tests__/plugin-environment-driver-seam.test.ts +++ b/server/src/__tests__/plugin-environment-driver-seam.test.ts @@ -159,6 +159,203 @@ describe("plugin environment driver seam", () => { }); }); +const objectReferenceManifest: PaperclipPluginManifestV1 = { + id: "test.external-object-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "External Object Provider", + description: "Test external object provider plugin", + author: "Paperclip", + categories: ["connector"], + capabilities: ["external.objects.detect", "external.objects.read"], + entrypoints: { worker: "dist/worker.js" }, + objectReferences: [ + { + providerKey: "mocktracker", + displayName: "Mock Tracker", + objectTypes: ["ticket"], + urlPatterns: ["https://mock.example/tickets/:id"], + refreshPolicy: { defaultTtlSeconds: 300, staleAfterSeconds: 1800 }, + }, + ], +}; + +describe("plugin external object provider seam", () => { + it("validates provider manifest declarations and capabilities", () => { + expect(pluginManifestV1Schema.safeParse(objectReferenceManifest).success).toBe(true); + + const missingCapability = pluginManifestV1Schema.safeParse({ + ...objectReferenceManifest, + capabilities: ["external.objects.detect"], + }); + expect(missingCapability.success).toBe(false); + expect(JSON.stringify(missingCapability.error?.issues)).toContain("external.objects.read"); + + const duplicateProvider = pluginManifestV1Schema.safeParse({ + ...objectReferenceManifest, + objectReferences: [ + objectReferenceManifest.objectReferences![0], + { ...objectReferenceManifest.objectReferences![0], displayName: "Duplicate" }, + ], + }); + expect(duplicateProvider.success).toBe(false); + expect(JSON.stringify(duplicateProvider.error?.issues)).toContain( + "Duplicate object reference provider keys", + ); + }); + + it("enforces provider capability requirements", () => { + const validator = pluginCapabilityValidator(); + expect(validator.getRequiredCapabilities("external.objects.detect")).toEqual([ + "external.objects.detect", + ]); + expect(validator.getRequiredCapabilities("external.objects.read")).toEqual([ + "external.objects.read", + ]); + expect(validator.checkOperation(objectReferenceManifest, "external.objects.read").allowed).toBe(true); + + const withoutCapability = { + ...objectReferenceManifest, + capabilities: ["external.objects.detect"], + } satisfies PaperclipPluginManifestV1; + + expect(validator.checkOperation(withoutCapability, "external.objects.read")).toMatchObject({ + allowed: false, + missing: ["external.objects.read"], + }); + expect(validator.validateManifestCapabilities(withoutCapability)).toMatchObject({ + allowed: false, + missing: ["external.objects.read"], + }); + }); + + it("dispatches provider detection and resolution worker hooks", async () => { + const plugin = definePlugin({ + async setup() {}, + async onDetectExternalObjects(params) { + return { + detections: params.urls.map((url) => ({ + urlIdentityHash: url.canonicalIdentityHash, + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + displayTitle: "Mock ticket", + confidence: "exact", + })), + }; + }, + async onResolveExternalObject(params) { + return { + ok: true, + snapshot: { + displayTitle: `Resolved ${params.externalId}`, + statusKey: "ready", + statusLabel: "Ready", + statusCategory: "succeeded", + statusTone: "success", + isTerminal: true, + ttlSeconds: 600, + }, + }; + }, + }); + + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const host = startWorkerRpcHost({ plugin, stdin, stdout }); + const responses: unknown[] = []; + stdout.on("data", (chunk) => { + const lines = String(chunk).split("\n").filter(Boolean); + for (const line of lines) { + responses.push(parseMessage(line)); + } + }); + + stdin.write(serializeMessage(createRequest("initialize", { + manifest: objectReferenceManifest, + config: {}, + instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, + apiVersion: 1, + }, 1))); + await waitForResponses(responses, 1); + + const initializeResponse = responses[0]; + expect(isJsonRpcSuccessResponse(initializeResponse)).toBe(true); + if (!isJsonRpcSuccessResponse(initializeResponse)) return; + expect(initializeResponse.result.supportedMethods).toContain("detectExternalObjects"); + expect(initializeResponse.result.supportedMethods).toContain("resolveExternalObject"); + + stdin.write(serializeMessage(createRequest("detectExternalObjects", { + companyId: "company-1", + urls: [{ + sanitizedCanonicalUrl: "https://mock.example/tickets/123", + sanitizedDisplayUrl: "https://mock.example/tickets/123", + canonicalIdentityHash: "hash-123", + canonicalIdentity: { scheme: "https", host: "mock.example", path: "/tickets/123" }, + redactedMatchedText: "https://mock.example/tickets/123", + }], + sourceContext: { + companyId: "company-1", + sourceIssueId: "issue-1", + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + propertyKey: null, + }, + }, 2))); + await waitForResponses(responses, 2); + + const detectResponse = responses[1]; + expect(isJsonRpcSuccessResponse(detectResponse)).toBe(true); + if (!isJsonRpcSuccessResponse(detectResponse)) return; + expect(detectResponse.result.detections[0]).toMatchObject({ + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + }); + + stdin.write(serializeMessage(createRequest("resolveExternalObject", { + companyId: "company-1", + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + object: { + id: "object-1", + companyId: "company-1", + providerKey: "mocktracker", + objectType: "ticket", + externalId: "MOCK-123", + sanitizedCanonicalUrl: "https://mock.example/tickets/123", + canonicalIdentityHash: "hash-123", + displayTitle: "Mock ticket", + statusKey: null, + statusLabel: null, + statusCategory: "unknown", + statusTone: "neutral", + liveness: "unknown", + isTerminal: false, + data: {}, + remoteVersion: null, + etag: null, + }, + }, 3))); + await waitForResponses(responses, 3); + + const resolveResponse = responses[2]; + expect(isJsonRpcSuccessResponse(resolveResponse)).toBe(true); + if (!isJsonRpcSuccessResponse(resolveResponse)) return; + expect(resolveResponse.result).toMatchObject({ + ok: true, + snapshot: { + statusCategory: "succeeded", + statusTone: "success", + }, + }); + + host.stop(); + }); +}); + async function waitForResponses(responses: unknown[], count: number): Promise { const deadline = Date.now() + 1_000; while (responses.length < count && Date.now() < deadline) { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index b0f9ac016a..9ba8780e93 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1536,6 +1536,97 @@ describe("realizeExecutionWorkspace", () => { } }); + it("regenerates stale worktree config that points at another host", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-stale-config-")); + const baseRoot = path.join(tempRoot, "base"); + const worktreeRoot = path.join(tempRoot, "worktree"); + const fakeBin = path.join(tempRoot, "bin"); + const fakePnpmPath = path.join(fakeBin, "pnpm"); + const scriptPath = path.join(worktreeRoot, "provision-worktree.sh"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + + try { + await fs.mkdir(baseRoot, { recursive: true }); + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(fakeBin, { recursive: true }); + await fs.copyFile(provisionWorktreeScriptPath, scriptPath); + await fs.chmod(scriptPath, 0o755); + await fs.writeFile( + path.join(paperclipDir, "config.json"), + JSON.stringify({ + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/Users/example/.paperclip-worktrees/instances/stale/db", + }, + logging: { + mode: "file", + logDir: "/Users/example/.paperclip-worktrees/instances/stale/logs", + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: "/Users/example/.paperclip-worktrees/instances/stale/data/storage", + }, + }, + secrets: { + provider: "local_encrypted", + localEncrypted: { + keyFilePath: "/Users/example/.paperclip-worktrees/instances/stale/secrets/master.key", + }, + }, + }), + "utf8", + ); + await fs.writeFile( + path.join(paperclipDir, ".env"), + [ + "PAPERCLIP_HOME=/Users/example/.paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=stale", + `PAPERCLIP_CONFIG=/Users/example/paperclip/${path.basename(worktreeRoot)}/.paperclip/config.json`, + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + fakePnpmPath, + [ + "#!/bin/sh", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"--help\" ]; then", + " exit 0", + "fi", + "if [ \"$1\" = \"paperclipai\" ] && [ \"$2\" = \"worktree\" ] && [ \"$3\" = \"init\" ]; then", + " mkdir -p \"$PWD/.paperclip\"", + " printf '%s\\n' '{\"database\":{\"embeddedPostgresDataDir\":\"'$PWD'/.paperclip/runtime/db\"}}' > \"$PWD/.paperclip/config.json\"", + " printf '%s\\n' \"PAPERCLIP_HOME=$PWD/.paperclip/runtime\" \"PAPERCLIP_INSTANCE_ID=healthy\" \"PAPERCLIP_CONFIG=$PWD/.paperclip/config.json\" > \"$PWD/.paperclip/.env\"", + " exit 0", + "fi", + "exit 0", + "", + ].join("\n"), + "utf8", + ); + await fs.chmod(fakePnpmPath, 0o755); + + const result = await execFileAsync(scriptPath, [], { + cwd: worktreeRoot, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + PAPERCLIP_WORKSPACE_BASE_CWD: baseRoot, + PAPERCLIP_WORKSPACE_CWD: worktreeRoot, + }, + }); + + expect(result.stderr).toContain("Existing isolated Paperclip worktree config is stale for this host; regenerating."); + await expect(fs.readFile(path.join(paperclipDir, ".env"), "utf8")).resolves.toContain( + `PAPERCLIP_CONFIG=${worktreeRoot}/.paperclip/config.json`, + ); + await expect(fs.readFile(path.join(paperclipDir, "config.json"), "utf8")).resolves.toContain(worktreeRoot); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it("retries worktree-local pnpm install without a frozen lockfile when the lockfile is outdated", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-outdated-lockfile-")); const baseRoot = path.join(tempRoot, "base"); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 21b7d01932..8e40055e9a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -148,11 +148,18 @@ import { resolveCoreTrustPreset, type TrustPresetResolution, } from "../services/trust-preset-resolver.js"; +import { externalObjectService } from "../services/external-objects.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; const updateIssueRouteSchema = updateIssueSchema.extend({ interrupt: z.boolean().optional(), }); +const refreshExternalObjectsSchema = z.object({ + objectIds: z.array(z.string().uuid()).max(50).optional(), +}).strict(); +const externalObjectSummariesSchema = z.object({ + issueIds: z.array(z.string().uuid()).max(1000), +}).strict(); const promoteLowTrustOutputSchema = z.object({ sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]), @@ -1105,6 +1112,10 @@ export function issueRoutes( ? heartbeat.wakeup : opts.taskWatchdogEnqueueWakeup ?? undefined, }) ?? noopTaskWatchdogService(); + const externalObjectsSvc = externalObjectService(db, { + pluginWorkerManager: opts.pluginWorkerManager, + enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true, + }); const routinesSvc = routineService(db, { pluginWorkerManager: opts.pluginWorkerManager, }); @@ -3730,6 +3741,69 @@ export function issueRoutes( res.json(workProducts); }); + router.get("/issues/:id/external-objects", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const objects = await externalObjectsSvc.listForIssue(issue.id); + res.json(objects); + }); + + router.get("/issues/:id/external-object-summary", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + const summary = await externalObjectsSvc.getIssueSummary(issue.id); + res.json(summary); + }); + + router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const summaries = await externalObjectsSvc.getIssueSummaries(companyId, req.body.issueIds); + res.json({ summaries: Object.fromEntries(summaries) }); + }); + + router.post("/issues/:id/external-objects/refresh", validate(refreshExternalObjectsSchema), async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; + const actor = getActorInfo(req); + const results = await externalObjectsSvc.refreshIssueObjects(issue.id, { + companyId: issue.companyId, + objectIds: req.body.objectIds, + actor, + }); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "external_object.refresh_requested", + entityType: "issue", + entityId: issue.id, + details: { + issueId: issue.id, + objectIds: results.map((result) => result.object.id), + }, + }); + res.json({ refreshed: results }); + }); + router.get("/issues/:id/documents", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -4016,6 +4090,7 @@ export function issueRoutes( const redirectedFromLockedDocument = "redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null; await issueReferencesSvc.syncDocument(doc.id); + await externalObjectsSvc.syncDocumentSafely(doc.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); const remappedAnnotations = result.created @@ -4245,6 +4320,7 @@ export function issueRoutes( }); await issueReferencesSvc.syncDocument(result.document.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + await externalObjectsSvc.syncDocumentSafely(result.document.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); const remappedAnnotations = await documentAnnotationsSvc.remapOpenThreadsForDocument({ issueId: issue.id, @@ -4358,6 +4434,7 @@ export function issueRoutes( } await issueReferencesSvc.deleteDocumentSource(removed.id); const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); + if (removed) await externalObjectsSvc.syncDocumentSafely(removed.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter); const actor = getActorInfo(req); await logActivity(db, { @@ -5025,6 +5102,7 @@ export function issueRoutes( watchdogActorRunId: actor.runId, }); await issueReferencesSvc.syncIssue(issue.id); + await externalObjectsSvc.syncIssueSafely(issue.id); const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary( issueReferencesSvc.emptySummary(), @@ -5190,6 +5268,7 @@ export function issueRoutes( actorUserId: actor.actorType === "user" ? actor.actorId : null, watchdogActorRunId: actor.runId, }); + await externalObjectsSvc.syncIssueSafely(issue.id); await logActivity(db, { companyId: parent.companyId, @@ -5958,6 +6037,7 @@ export function issueRoutes( if (titleOrDescriptionChanged) { await issueReferencesSvc.syncIssue(issue.id); + await externalObjectsSvc.syncIssueSafely(issue.id); } const updateReferenceSummaryAfter = titleOrDescriptionChanged ? await issueReferencesSvc.listIssueReferenceSummary(issue.id) @@ -6263,6 +6343,7 @@ export function issueRoutes( sourceTrust: await sourceTrustForActorWrite(issue, actor), }); await issueReferencesSvc.syncComment(comment.id); + await externalObjectsSvc.syncCommentSafely(comment.id); const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id); const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary( commentReferenceSummaryBefore, @@ -7256,6 +7337,7 @@ export function issueRoutes( { afterTombstone: async (deletedComment, tx) => { await issueReferencesSvc.syncComment(deletedComment.id, tx); + await externalObjectsSvc.syncCommentSafely(deletedComment.id, tx); annotationCleanup = await documentAnnotationsSvc.cleanupForIssueCommentDeletion(issue.id, deletedComment.id, { actorType: actor.actorType, actorId: actor.actorId, @@ -7265,7 +7347,10 @@ export function issueRoutes( }, tx); await Promise.all( annotationCleanup.deletedCommentIds.map((annotationCommentId) => - issueReferencesSvc.deleteCommentSource(annotationCommentId, tx) + Promise.all([ + issueReferencesSvc.deleteCommentSource(annotationCommentId, tx), + externalObjectsSvc.syncCommentSafely(annotationCommentId, tx), + ]) ), ); }, @@ -7705,6 +7790,7 @@ export function issueRoutes( } await issueReferencesSvc.syncComment(comment.id); + await externalObjectsSvc.syncCommentSafely(comment.id); const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id); const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary( commentReferenceSummaryBefore, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index ccf38a30e0..82ac734ef6 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -447,6 +447,14 @@ const jsonBody = (schema: z.ZodTypeAny) => ({ const r = responses; +const externalObjectSummariesBodySchema = z.object({ + issueIds: z.array(z.string().uuid()).max(1000), +}).strict(); + +const refreshExternalObjectsBodySchema = z.object({ + objectIds: z.array(z.string().uuid()).max(50).optional(), +}).strict(); + function paramsSchemaFromPath(routePath: string): z.ZodObject | undefined { const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]); if (names.length === 0) return undefined; @@ -3847,6 +3855,57 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/external-objects", + tags: ["issues"], + summary: "List external objects mentioned by an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{id}/external-object-summary", + tags: ["issues"], + summary: "Get external object status summary for an issue", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/issues/external-object-summaries", + tags: ["issues"], + summary: "Get external object status summaries for issues", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(externalObjectSummariesBodySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "post", + path: "/api/issues/{id}/external-objects/refresh", + tags: ["issues"], + summary: "Refresh external objects mentioned by an issue", + request: { + params: z.object({ id: z.string() }), + body: jsonBody(refreshExternalObjectsBodySchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/projects/{id}/external-object-summary", + tags: ["projects"], + summary: "Get external object status summary for a project", + request: { params: z.object({ id: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + // ─── Org chart images ───────────────────────────────────────────────────────── registry.registerPath({ diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 6952cdd8a8..c7e123fc0d 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -15,6 +15,8 @@ import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict, forbidden } from "../errors.js"; +import { externalObjectService } from "../services/external-objects.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { buildWorkspaceRuntimeDesiredStatePatch, @@ -44,6 +46,10 @@ export function projectRoutes(db: Db) { const access = accessService(db); const secretsSvc = secretService(db); const workspaceOperations = workspaceOperationService(db); + const instanceSettings = instanceSettingsService(db); + const externalObjectsSvc = externalObjectService(db, { + enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true, + }); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const environmentsSvc = environmentService(db); @@ -139,6 +145,18 @@ export function projectRoutes(db: Db) { res.json(project); }); + router.get("/projects/:id/external-object-summary", async (req, res) => { + const id = req.params.id as string; + const project = await svc.getById(id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, project.companyId); + const summary = await externalObjectsSvc.getProjectSummary(project.id); + res.json(summary); + }); + router.post("/companies/:companyId/projects", validate(createProjectSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/external-objects.ts b/server/src/services/external-objects.ts new file mode 100644 index 0000000000..4823a80fe4 --- /dev/null +++ b/server/src/services/external-objects.ts @@ -0,0 +1,945 @@ +import { and, asc, eq, inArray, isNull, lte, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { documents, externalObjectMentions, externalObjects, issueComments, issueDocuments, issues, plugins } from "@paperclipai/db"; +import { + formatExternalObjectMentionSourceLabel, + type ExternalObjectCanonicalUrl, + type ExternalObjectLivenessState, + type ExternalObjectMentionConfidence, + type ExternalObjectMentionSourceKind, + type ExternalObjectStatusCategory, + type ExternalObjectStatusTone, + type PaperclipPluginManifestV1, +} from "@paperclipai/shared"; +import { extractExternalObjectCanonicalUrls } from "@paperclipai/shared/external-objects-server"; +import type { PluginExternalObjectRecordSnapshot, PluginExternalObjectResolveResult } from "@paperclipai/plugin-sdk"; +import { notFound } from "../errors.js"; +import { logger } from "../middleware/logger.js"; +import { logActivity, type LogActivityInput } from "./activity-log.js"; +import { createGitHubExternalObjectProvider, type GitHubExternalObjectProviderOptions } from "./github-external-object-provider.js"; +import { publishLiveEvent } from "./live-events.js"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; + +export interface ExternalObjectSourceContext { + companyId: string; + sourceIssueId: string; + sourceKind: ExternalObjectMentionSourceKind; + sourceRecordId: string | null; + documentKey: string | null; + propertyKey: string | null; +} + +export interface ExternalObjectDetection { + canonical: ExternalObjectCanonicalUrl; + detectorKey: string; + providerKey: string; + objectType: string; + externalId: string; + displayKey?: string | null; + iconKey?: string | null; + displayTitle?: string | null; + confidence?: ExternalObjectMentionConfidence; + pluginId?: string | null; +} + +export interface ExternalObjectDetector { + key: string; + detect(input: { + companyId: string; + urls: ExternalObjectCanonicalUrl[]; + sourceContext: ExternalObjectSourceContext; + }): Promise | ExternalObjectDetection[]; +} + +export interface ExternalObjectResolverSnapshot { + displayKey?: string | null; + iconKey?: string | null; + displayTitle?: string | null; + statusKey?: string | null; + statusLabel?: string | null; + statusIconKey?: string | null; + statusCategory: ExternalObjectStatusCategory; + statusTone: ExternalObjectStatusTone; + isTerminal?: boolean; + data?: Record; + remoteVersion?: string | null; + etag?: string | null; + ttlSeconds?: number; +} + +export type ExternalObjectResolveResult = + | { ok: true; snapshot: ExternalObjectResolverSnapshot } + | { + ok: false; + liveness: Extract; + errorCode: string; + errorMessage?: string | null; + retryAfterSeconds?: number; + }; + +export interface ExternalObjectResolver { + providerKey: string; + objectType?: string; + resolve(input: { + companyId: string; + object: ExternalObjectRecord; + }): Promise; +} + +type ExternalObjectRecord = typeof externalObjects.$inferSelect; +type ExternalObjectMentionRecord = typeof externalObjectMentions.$inferSelect; + +const DEFAULT_REFRESH_TTL_SECONDS = 300; +const DEFAULT_RETRY_AFTER_SECONDS = 300; + +function sourceWhere(input: ExternalObjectSourceContext) { + const conditions = [ + eq(externalObjectMentions.companyId, input.companyId), + eq(externalObjectMentions.sourceIssueId, input.sourceIssueId), + eq(externalObjectMentions.sourceKind, input.sourceKind), + ]; + if (input.sourceRecordId) { + conditions.push(eq(externalObjectMentions.sourceRecordId, input.sourceRecordId)); + } else { + conditions.push(isNull(externalObjectMentions.sourceRecordId)); + } + if (input.documentKey) { + conditions.push(eq(externalObjectMentions.documentKey, input.documentKey)); + } else { + conditions.push(isNull(externalObjectMentions.documentKey)); + } + if (input.propertyKey) { + conditions.push(eq(externalObjectMentions.propertyKey, input.propertyKey)); + } else { + conditions.push(isNull(externalObjectMentions.propertyKey)); + } + return and(...conditions); +} + +function addSeconds(date: Date, seconds: number) { + return new Date(date.getTime() + Math.max(1, seconds) * 1000); +} + +function visibleLiveness(object: ExternalObjectRecord, now = new Date()): ExternalObjectLivenessState { + if (object.liveness === "fresh" && object.nextRefreshAt && object.nextRefreshAt <= now) { + return "stale"; + } + return object.liveness; +} + +function objectChanged(before: ExternalObjectRecord, after: ExternalObjectRecord) { + return ( + before.statusKey !== after.statusKey || + before.statusLabel !== after.statusLabel || + before.statusIconKey !== after.statusIconKey || + before.statusCategory !== after.statusCategory || + before.statusTone !== after.statusTone || + before.isTerminal !== after.isTerminal + ); +} + +function sanitizeErrorMessage(message: string | null | undefined) { + if (!message) return null; + return message + .replace(/https?:\/\/[^\s<>()]+/gi, "[redacted-url]") + .replace(/\b(token|key|secret|authorization|bearer)=\S+/gi, "$1=[redacted]"); +} + +function genericUrlDetector(): ExternalObjectDetector { + return { + key: "generic-url", + detect({ urls }) { + return urls.map((canonical) => ({ + canonical, + detectorKey: "generic-url", + providerKey: "url", + objectType: "link", + externalId: canonical.canonicalIdentityHash, + displayTitle: canonical.sanitizedDisplayUrl, + confidence: "possible", + })); + }, + }; +} + +export function createExternalObjectDetectorRegistry(detectors: ExternalObjectDetector[] = []) { + const entries = [...detectors, genericUrlDetector()]; + + async function detect(input: { + companyId: string; + urls: ExternalObjectCanonicalUrl[]; + sourceContext: ExternalObjectSourceContext; + }) { + const claimed = new Set(); + const detections: ExternalObjectDetection[] = []; + for (const detector of entries) { + const remaining = input.urls.filter((url) => !claimed.has(url.canonicalIdentityHash)); + if (remaining.length === 0) break; + try { + const detected = await detector.detect({ ...input, urls: remaining }); + for (const detection of detected) { + if (claimed.has(detection.canonical.canonicalIdentityHash)) continue; + claimed.add(detection.canonical.canonicalIdentityHash); + detections.push({ ...detection, detectorKey: detection.detectorKey || detector.key }); + } + } catch (err) { + logger.warn({ err, detectorKey: detector.key }, "external object detector failed"); + } + } + return detections; + } + + return { detect }; +} + +export function createExternalObjectResolverRegistry(resolvers: ExternalObjectResolver[] = []) { + function find(object: Pick) { + return resolvers.find( + (resolver) => + resolver.providerKey === object.providerKey && + (!resolver.objectType || resolver.objectType === object.objectType), + ) ?? null; + } + return { find }; +} + +function manifestProvidesObject( + manifest: PaperclipPluginManifestV1, + object: Pick, +) { + return (manifest.objectReferences ?? []).some( + (provider) => + provider.providerKey === object.providerKey && + provider.objectTypes.includes(object.objectType), + ); +} + +function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecordSnapshot { + return { + id: object.id, + companyId: object.companyId, + providerKey: object.providerKey, + objectType: object.objectType, + externalId: object.externalId, + sanitizedCanonicalUrl: object.sanitizedCanonicalUrl, + canonicalIdentityHash: object.canonicalIdentityHash, + displayKey: object.displayKey, + iconKey: object.iconKey, + displayTitle: object.displayTitle, + statusKey: object.statusKey, + statusLabel: object.statusLabel, + statusIconKey: object.statusIconKey, + statusCategory: object.statusCategory, + statusTone: object.statusTone, + liveness: object.liveness, + isTerminal: object.isTerminal, + data: object.data as Record, + remoteVersion: object.remoteVersion, + etag: object.etag, + }; +} + +async function readyObjectReferencePlugins(db: Db) { + return db + .select({ + id: plugins.id, + pluginKey: plugins.pluginKey, + manifestJson: plugins.manifestJson, + }) + .from(plugins) + .where(eq(plugins.status, "ready")) + .orderBy(asc(plugins.installOrder)) + .then((rows) => + rows.filter((row) => (row.manifestJson.objectReferences?.length ?? 0) > 0), + ); +} + +function createPluginProviderDetector( + db: Db, + pluginWorkerManager: PluginWorkerManager, +): ExternalObjectDetector { + return { + key: "plugin-object-reference-providers", + async detect(input) { + const providers = await readyObjectReferencePlugins(db); + const detections: ExternalObjectDetection[] = []; + + for (const provider of providers) { + const manifest = provider.manifestJson; + if (!manifest.capabilities.includes("external.objects.detect")) continue; + try { + const result = await pluginWorkerManager.call(provider.id, "detectExternalObjects", { + companyId: input.companyId, + urls: input.urls.map((url) => ({ + sanitizedCanonicalUrl: url.sanitizedCanonicalUrl, + sanitizedDisplayUrl: url.sanitizedDisplayUrl, + canonicalIdentityHash: url.canonicalIdentityHash, + canonicalIdentity: url.canonicalIdentity as unknown as Record, + redactedMatchedText: url.redactedMatchedText, + })), + sourceContext: input.sourceContext, + }); + const urlsByHash = new Map(input.urls.map((url) => [url.canonicalIdentityHash, url])); + const declaredProviderKeys = new Set((manifest.objectReferences ?? []).map((entry) => entry.providerKey)); + + for (const detection of result.detections ?? []) { + const canonical = urlsByHash.get(detection.urlIdentityHash); + if (!canonical) continue; + if (!declaredProviderKeys.has(detection.providerKey)) continue; + const declaration = (manifest.objectReferences ?? []).find((entry) => entry.providerKey === detection.providerKey); + if (!declaration?.objectTypes.includes(detection.objectType)) continue; + detections.push({ + canonical, + detectorKey: `${provider.pluginKey}:${detection.providerKey}`, + providerKey: detection.providerKey, + objectType: detection.objectType, + externalId: detection.externalId, + displayKey: detection.displayKey, + iconKey: detection.iconKey, + displayTitle: detection.displayTitle, + confidence: detection.confidence ?? "exact", + pluginId: provider.id, + }); + } + } catch (err) { + logger.warn( + { err, pluginId: provider.id, pluginKey: provider.pluginKey }, + "plugin external object detector failed", + ); + } + } + + return detections; + }, + }; +} + +async function resolveViaPluginProvider( + db: Db, + pluginWorkerManager: PluginWorkerManager | undefined, + object: ExternalObjectRecord, +): Promise { + if (!pluginWorkerManager) return null; + const providers = await readyObjectReferencePlugins(db); + for (const provider of providers) { + const manifest = provider.manifestJson; + if (!manifest.capabilities.includes("external.objects.read")) continue; + if (!manifestProvidesObject(manifest, object)) continue; + try { + return await pluginWorkerManager.call(provider.id, "resolveExternalObject", { + companyId: object.companyId, + providerKey: object.providerKey, + objectType: object.objectType, + externalId: object.externalId, + object: objectSnapshot(object), + }); + } catch (err) { + logger.warn( + { err, pluginId: provider.id, pluginKey: provider.pluginKey, objectId: object.id }, + "plugin external object resolver failed", + ); + return { + ok: false, + liveness: "unreachable", + errorCode: "plugin_resolver_failed", + errorMessage: err instanceof Error ? err.message : String(err), + }; + } + } + return null; +} + +export function externalObjectService( + db: Db, + opts: { + detectors?: ExternalObjectDetector[]; + resolvers?: ExternalObjectResolver[]; + pluginWorkerManager?: PluginWorkerManager; + github?: GitHubExternalObjectProviderOptions | false; + enabled?: boolean | (() => boolean | Promise); + } = {}, +) { + const githubProvider = opts.github === false ? null : createGitHubExternalObjectProvider(db, opts.github); + const pluginProviderDetector = opts.pluginWorkerManager + ? createPluginProviderDetector(db, opts.pluginWorkerManager) + : null; + const detectorRegistry = createExternalObjectDetectorRegistry([ + ...(pluginProviderDetector ? [pluginProviderDetector] : []), + ...(opts.detectors ?? []), + ...(githubProvider ? [githubProvider.detector] : []), + ]); + const resolverRegistry = createExternalObjectResolverRegistry([ + ...(opts.resolvers ?? []), + ...(githubProvider?.resolvers ?? []), + ]); + + async function isEnabled() { + if (typeof opts.enabled === "function") return await opts.enabled(); + return opts.enabled ?? true; + } + + function emptySummary() { + return summarizeObjectPayloads([]); + } + + async function issueById(issueId: string, dbOrTx: any = db) { + return dbOrTx + .select({ + id: issues.id, + companyId: issues.companyId, + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ id: string; companyId: string; title: string; description: string | null }>) => rows[0] ?? null); + } + + async function upsertObjectFromDetection( + companyId: string, + detection: ExternalObjectDetection, + dbOrTx: any, + ): Promise { + const now = new Date(); + const canonical = detection.canonical; + const values = { + companyId, + providerKey: detection.providerKey, + pluginId: detection.pluginId ?? null, + objectType: detection.objectType, + externalId: detection.externalId, + sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl, + canonicalIdentityHash: canonical.canonicalIdentityHash, + displayKey: detection.displayKey ?? null, + iconKey: detection.iconKey ?? null, + displayTitle: detection.displayTitle ?? canonical.sanitizedDisplayUrl, + nextRefreshAt: now, + updatedAt: now, + }; + const inserted = await dbOrTx + .insert(externalObjects) + .values(values) + .onConflictDoUpdate({ + target: [ + externalObjects.companyId, + externalObjects.providerKey, + externalObjects.objectType, + externalObjects.externalId, + ], + set: { + sanitizedCanonicalUrl: values.sanitizedCanonicalUrl, + canonicalIdentityHash: values.canonicalIdentityHash, + displayKey: values.displayKey, + iconKey: values.iconKey, + displayTitle: values.displayTitle, + nextRefreshAt: sql`coalesce(${externalObjects.nextRefreshAt}, now())`, + updatedAt: now, + }, + }) + .returning(); + return inserted[0]!; + } + + async function replaceSourceMentions( + input: ExternalObjectSourceContext & { text: string | null | undefined }, + dbOrTx: any = db, + ) { + const urls = extractExternalObjectCanonicalUrls(input.text ?? ""); + await dbOrTx.delete(externalObjectMentions).where(sourceWhere(input)); + if (urls.length === 0) return; + + const detections = await detectorRegistry.detect({ + companyId: input.companyId, + urls, + sourceContext: input, + }); + if (detections.length === 0) return; + + const seen = new Set(); + const values: Array = []; + for (const detection of detections) { + const canonicalHash = detection.canonical.canonicalIdentityHash; + const sourceKey = `${detection.providerKey}:${detection.objectType}:${canonicalHash}`; + if (seen.has(sourceKey)) continue; + seen.add(sourceKey); + const object = await upsertObjectFromDetection(input.companyId, detection, dbOrTx); + values.push({ + companyId: input.companyId, + sourceIssueId: input.sourceIssueId, + sourceKind: input.sourceKind, + sourceRecordId: input.sourceRecordId, + documentKey: input.documentKey, + propertyKey: input.propertyKey, + matchedTextRedacted: detection.canonical.redactedMatchedText, + sanitizedDisplayUrl: detection.canonical.sanitizedDisplayUrl, + canonicalIdentityHash: canonicalHash, + canonicalIdentity: detection.canonical.canonicalIdentity as unknown as Record, + objectId: object.id, + providerKey: detection.providerKey, + detectorKey: detection.detectorKey, + objectType: detection.objectType, + confidence: detection.confidence ?? "exact", + createdByPluginId: detection.pluginId ?? null, + }); + } + if (values.length > 0) { + await dbOrTx.insert(externalObjectMentions).values(values); + } + } + + async function syncIssue(issueId: string, dbOrTx: any = db) { + if (!(await isEnabled())) return; + const runSync = async (tx: any) => { + const issue = await issueById(issueId, tx); + if (!issue) throw notFound("Issue not found"); + await replaceSourceMentions({ + companyId: issue.companyId, + sourceIssueId: issue.id, + sourceKind: "title", + sourceRecordId: null, + documentKey: null, + propertyKey: null, + text: issue.title, + }, tx); + await replaceSourceMentions({ + companyId: issue.companyId, + sourceIssueId: issue.id, + sourceKind: "description", + sourceRecordId: null, + documentKey: null, + propertyKey: null, + text: issue.description, + }, tx); + }; + return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx); + } + + async function syncComment(commentId: string, dbOrTx: any = db) { + const runSync = async (tx: any) => { + if (!(await isEnabled())) return; + const comment = await tx + .select({ + id: issueComments.id, + companyId: issueComments.companyId, + issueId: issueComments.issueId, + body: issueComments.body, + }) + .from(issueComments) + .where(eq(issueComments.id, commentId)) + .then((rows: Array<{ id: string; companyId: string; issueId: string; body: string }>) => rows[0] ?? null); + if (!comment) { + await tx + .delete(externalObjectMentions) + .where(and(eq(externalObjectMentions.sourceKind, "comment"), eq(externalObjectMentions.sourceRecordId, commentId))); + return; + } + await replaceSourceMentions({ + companyId: comment.companyId, + sourceIssueId: comment.issueId, + sourceKind: "comment", + sourceRecordId: comment.id, + documentKey: null, + propertyKey: null, + text: comment.body, + }, tx); + }; + return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx); + } + + async function syncDocument(documentId: string, dbOrTx: any = db) { + const runSync = async (tx: any) => { + if (!(await isEnabled())) return; + const document = await tx + .select({ + documentId: documents.id, + companyId: documents.companyId, + issueId: issueDocuments.issueId, + key: issueDocuments.key, + body: documents.latestBody, + }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(eq(documents.id, documentId)) + .then((rows: Array<{ documentId: string; companyId: string; issueId: string; key: string; body: string }>) => rows[0] ?? null); + if (!document) { + await tx + .delete(externalObjectMentions) + .where(and(eq(externalObjectMentions.sourceKind, "document"), eq(externalObjectMentions.sourceRecordId, documentId))); + return; + } + await replaceSourceMentions({ + companyId: document.companyId, + sourceIssueId: document.issueId, + sourceKind: "document", + sourceRecordId: document.documentId, + documentKey: document.key, + propertyKey: null, + text: document.body, + }, tx); + }; + return dbOrTx === db ? db.transaction(runSync) : runSync(dbOrTx); + } + + async function safeSync(label: string, fn: () => Promise) { + try { + await fn(); + } catch (err) { + logger.warn({ err }, `external object ${label} sync failed`); + } + } + + async function syncIssueSafely(issueId: string) { + await safeSync("issue", () => syncIssue(issueId)); + } + + async function syncCommentSafely(commentId: string, dbOrTx: any = db) { + await safeSync("comment", () => syncComment(commentId, dbOrTx)); + } + + async function syncDocumentSafely(documentId: string) { + await safeSync("document", () => syncDocument(documentId)); + } + + function toObjectPayload(object: ExternalObjectRecord, now = new Date()) { + return { + ...object, + liveness: visibleLiveness(object, now), + }; + } + + async function listForIssue(issueId: string) { + if (!(await isEnabled())) return []; + const issue = await issueById(issueId); + if (!issue) throw notFound("Issue not found"); + const rows = await db + .select({ + mention: externalObjectMentions, + object: externalObjects, + }) + .from(externalObjectMentions) + .leftJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id)) + .where(and( + eq(externalObjectMentions.companyId, issue.companyId), + eq(externalObjectMentions.sourceIssueId, issue.id), + )) + .orderBy(asc(externalObjectMentions.sourceKind), asc(externalObjectMentions.createdAt)); + const now = new Date(); + const grouped = new Map | null; + mentions: ExternalObjectMentionRecord[]; + mentionCount: number; + sourceLabels: string[]; + }>(); + for (const row of rows) { + const key = row.object?.id ?? `mention:${row.mention.id}`; + const existing = grouped.get(key) ?? { + object: row.object ? toObjectPayload(row.object, now) : null, + mentions: [], + mentionCount: 0, + sourceLabels: [], + }; + existing.mentions.push(row.mention); + existing.mentionCount += 1; + const label = formatExternalObjectMentionSourceLabel({ + sourceKind: row.mention.sourceKind, + documentKey: row.mention.documentKey, + propertyKey: row.mention.propertyKey, + }); + if (!existing.sourceLabels.includes(label)) existing.sourceLabels.push(label); + grouped.set(key, existing); + } + return [...grouped.values()]; + } + + function summarizeObjects(objects: Array>) { + const byStatusCategory: Record = {}; + const byLiveness: Record = {}; + let highestSeverity: ExternalObjectStatusTone = "neutral"; + const severityRank: Record = { + neutral: 0, + muted: 0, + success: 1, + info: 2, + warning: 3, + danger: 4, + }; + for (const object of objects) { + byStatusCategory[object.statusCategory] = (byStatusCategory[object.statusCategory] ?? 0) + 1; + byLiveness[object.liveness] = (byLiveness[object.liveness] ?? 0) + 1; + const livenessTone = object.liveness === "auth_required" || object.liveness === "unreachable" + ? "danger" + : object.liveness === "stale" + ? "warning" + : object.statusTone; + if (severityRank[livenessTone] > severityRank[highestSeverity]) highestSeverity = livenessTone; + } + return { + total: objects.length, + byStatusCategory, + byLiveness, + highestSeverity, + staleCount: byLiveness.stale ?? 0, + authRequiredCount: byLiveness.auth_required ?? 0, + unreachableCount: byLiveness.unreachable ?? 0, + }; + } + + function summarizeObjectPayloads(objects: Array>, objectLimit = objects.length) { + return { + ...summarizeObjects(objects), + objects: objects.slice(0, objectLimit).map((object) => ({ + id: object.id, + providerKey: object.providerKey, + objectType: object.objectType, + displayKey: object.displayKey, + iconKey: object.iconKey, + displayTitle: object.displayTitle, + statusIconKey: object.statusIconKey, + statusCategory: object.statusCategory, + statusTone: object.statusTone, + liveness: object.liveness, + isTerminal: object.isTerminal, + })), + }; + } + + async function getIssueSummary(issueId: string) { + if (!(await isEnabled())) return emptySummary(); + const groups = await listForIssue(issueId); + const objects = groups.flatMap((group) => (group.object ? [group.object] : [])); + return summarizeObjectPayloads(objects); + } + + async function getIssueSummaries(companyId: string, issueIds: string[]) { + if (!(await isEnabled())) return new Map>(); + const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0); + const summaries = new Map>(); + if (uniqueIssueIds.length === 0) return summaries; + + const rows = await db + .select({ + issueId: externalObjectMentions.sourceIssueId, + object: externalObjects, + }) + .from(externalObjectMentions) + .innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id)) + .where(and( + eq(externalObjectMentions.companyId, companyId), + inArray(externalObjectMentions.sourceIssueId, uniqueIssueIds), + )); + + const now = new Date(); + const objectsByIssueId = new Map>>(); + for (const row of rows) { + const issueObjects = objectsByIssueId.get(row.issueId) ?? new Map>(); + issueObjects.set(row.object.id, toObjectPayload(row.object, now)); + objectsByIssueId.set(row.issueId, issueObjects); + } + + for (const [issueId, issueObjects] of objectsByIssueId) { + const objects = [...issueObjects.values()]; + if (objects.length > 0) summaries.set(issueId, summarizeObjectPayloads(objects)); + } + return summaries; + } + + async function getProjectSummary(projectId: string) { + if (!(await isEnabled())) return emptySummary(); + const projectIssues = await db + .select({ id: issues.id, companyId: issues.companyId }) + .from(issues) + .where(and(eq(issues.projectId, projectId), inArray(issues.status, ["todo", "in_progress", "in_review", "blocked"]))); + if (projectIssues.length === 0) return { ...summarizeObjects([]), objects: [] }; + const companyIds = new Set(projectIssues.map((issue) => issue.companyId)); + if (companyIds.size !== 1) return { ...summarizeObjects([]), objects: [] }; + const issueIds = projectIssues.map((issue) => issue.id); + const rows = await db + .select({ object: externalObjects }) + .from(externalObjectMentions) + .innerJoin(externalObjects, eq(externalObjectMentions.objectId, externalObjects.id)) + .where(and( + eq(externalObjectMentions.companyId, projectIssues[0]!.companyId), + inArray(externalObjectMentions.sourceIssueId, issueIds), + )); + const now = new Date(); + const objectsById = new Map>(); + for (const row of rows) objectsById.set(row.object.id, toObjectPayload(row.object, now)); + const objects = [...objectsById.values()]; + return summarizeObjectPayloads(objects, 25); + } + + async function refreshObject( + objectId: string, + input: { + companyId: string; + actor?: Pick; + force?: boolean; + now?: Date; + }, + ) { + const now = input.now ?? new Date(); + const object = await db + .select() + .from(externalObjects) + .where(and(eq(externalObjects.id, objectId), eq(externalObjects.companyId, input.companyId))) + .then((rows) => rows[0] ?? null); + if (!object) throw notFound("External object not found"); + if (!input.force && object.nextRefreshAt && object.nextRefreshAt > now) { + return { object: toObjectPayload(object, now), refreshed: false, reason: "backoff" as const }; + } + + const pluginResult = await resolveViaPluginProvider(db, opts.pluginWorkerManager, object); + const resolver = pluginResult ? null : resolverRegistry.find(object); + if (!pluginResult && !resolver) { + const [updated] = await db + .update(externalObjects) + .set({ + liveness: visibleLiveness(object, now) === "fresh" ? "stale" : object.liveness, + nextRefreshAt: addSeconds(now, DEFAULT_RETRY_AFTER_SECONDS), + updatedAt: now, + }) + .where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId))) + .returning(); + return { object: toObjectPayload(updated ?? object, now), refreshed: false, reason: "no_resolver" as const }; + } + + const result = pluginResult ?? await resolver!.resolve({ companyId: object.companyId, object }); + if (!result.ok) { + const [updated] = await db + .update(externalObjects) + .set({ + liveness: result.liveness, + lastErrorAt: now, + lastErrorCode: result.errorCode, + lastErrorMessage: sanitizeErrorMessage(result.errorMessage), + nextRefreshAt: addSeconds(now, result.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS), + updatedAt: now, + }) + .where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId))) + .returning(); + publishLiveEvent({ + companyId: object.companyId, + type: "external_object.updated", + payload: { objectId: object.id, liveness: result.liveness }, + }); + return { object: toObjectPayload(updated ?? object, now), refreshed: true, reason: result.liveness }; + } + + const snapshot = result.snapshot; + const patch = { + displayKey: snapshot.displayKey ?? object.displayKey, + iconKey: snapshot.iconKey ?? object.iconKey, + displayTitle: snapshot.displayTitle ?? object.displayTitle, + statusKey: snapshot.statusKey ?? object.statusKey, + statusLabel: snapshot.statusLabel ?? object.statusLabel, + statusIconKey: snapshot.statusIconKey ?? object.statusIconKey, + statusCategory: snapshot.statusCategory, + statusTone: snapshot.statusTone, + isTerminal: snapshot.isTerminal ?? object.isTerminal, + data: snapshot.data ?? object.data, + remoteVersion: snapshot.remoteVersion ?? object.remoteVersion, + etag: snapshot.etag ?? object.etag, + liveness: "fresh" as ExternalObjectLivenessState, + lastResolvedAt: now, + lastErrorAt: null, + lastErrorCode: null, + lastErrorMessage: null, + nextRefreshAt: addSeconds(now, snapshot.ttlSeconds ?? DEFAULT_REFRESH_TTL_SECONDS), + updatedAt: now, + }; + const [updated] = await db + .update(externalObjects) + .set({ + ...patch, + lastChangedAt: objectChanged(object, { ...object, ...patch }) ? now : object.lastChangedAt, + }) + .where(and(eq(externalObjects.id, object.id), eq(externalObjects.companyId, object.companyId))) + .returning(); + const next = updated ?? object; + if (objectChanged(object, next) && input.actor) { + await logActivity(db, { + companyId: object.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + action: "external_object.status_changed", + entityType: "external_object", + entityId: object.id, + details: { + providerKey: object.providerKey, + objectType: object.objectType, + statusCategory: next.statusCategory, + statusLabel: next.statusLabel, + _previous: { + statusCategory: object.statusCategory, + statusLabel: object.statusLabel, + }, + }, + }); + } + publishLiveEvent({ + companyId: object.companyId, + type: "external_object.updated", + payload: { objectId: object.id, statusCategory: next.statusCategory, liveness: next.liveness }, + }); + return { object: toObjectPayload(next, now), refreshed: true, reason: "resolved" as const }; + } + + async function refreshIssueObjects(issueId: string, input: { + companyId: string; + objectIds?: string[]; + actor?: Pick; + }) { + if (!(await isEnabled())) return []; + const groups = await listForIssue(issueId); + const objectIds = groups + .flatMap((group) => (group.object ? [group.object.id] : [])) + .filter((id) => !input.objectIds || input.objectIds.includes(id)); + const results = []; + for (const objectId of objectIds) { + results.push(await refreshObject(objectId, { companyId: input.companyId, actor: input.actor })); + } + return results; + } + + async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) { + if (!(await isEnabled())) return []; + const due = await db + .select({ id: externalObjects.id }) + .from(externalObjects) + .where( + and( + eq(externalObjects.companyId, companyId), + eq(externalObjects.isTerminal, false), + lte(externalObjects.nextRefreshAt, now), + ), + ) + .limit(limit); + const results = []; + for (const row of due) { + results.push(await refreshObject(row.id, { + companyId, + actor: { actorType: "system", actorId: "external-object-resolver", agentId: null, runId: null }, + now, + })); + } + return results; + } + + return { + syncIssue, + syncComment, + syncDocument, + syncIssueSafely, + syncCommentSafely, + syncDocumentSafely, + listForIssue, + getIssueSummary, + getIssueSummaries, + getProjectSummary, + refreshObject, + refreshIssueObjects, + refreshDueObjects, + }; +} diff --git a/server/src/services/github-external-object-provider.ts b/server/src/services/github-external-object-provider.ts new file mode 100644 index 0000000000..f434b24b41 --- /dev/null +++ b/server/src/services/github-external-object-provider.ts @@ -0,0 +1,445 @@ +import type { Db } from "@paperclipai/db"; +import type { ExternalObjectCanonicalUrl } from "@paperclipai/shared"; +import { ghFetch, gitHubApiBase } from "./github-fetch.js"; +import { secretService } from "./secrets.js"; +import type { + ExternalObjectDetection, + ExternalObjectDetector, + ExternalObjectResolver, + ExternalObjectResolverSnapshot, + ExternalObjectResolveResult, +} from "./external-objects.js"; + +type FetchLike = (url: string, init?: RequestInit) => Promise; + +export interface GitHubExternalObjectProviderOptions { + fetch?: FetchLike; + tokenProvider?: (companyId: string) => Promise | string | null; + secretNames?: readonly string[]; +} + +interface GitHubObjectIdentity { + host: string; + owner: string; + repo: string; + number: number; + objectType: "pull_request" | "issue"; + pathKind: "pull" | "issues"; +} + +const DEFAULT_GITHUB_TOKEN_SECRET_NAMES = ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"] as const; +const GITHUB_OBJECT_TTL_SECONDS = 300; + +function isGitHubHost(host: string) { + const h = host.toLowerCase(); + return h === "github.com" || h === "www.github.com"; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function asString(value: unknown) { + return typeof value === "string" ? value : null; +} + +function asBoolean(value: unknown) { + return typeof value === "boolean" ? value : null; +} + +function asNestedString(record: Record, key: string, nestedKey: string) { + const nested = asRecord(record[key]); + return nested ? asString(nested[nestedKey]) : null; +} + +function parseGitHubCanonicalUrl(canonical: ExternalObjectCanonicalUrl): GitHubObjectIdentity | null { + if (canonical.canonicalIdentity.scheme !== "https") return null; + const host = canonical.canonicalIdentity.host.toLowerCase(); + if (!isGitHubHost(host)) return null; + + const parts = canonical.canonicalIdentity.path.split("/").filter(Boolean); + if (parts.length !== 4) return null; + const [owner, repo, kind, rawNumber] = parts; + if (!owner || !repo || !kind || !rawNumber) return null; + if (!/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return null; + if (!/^[1-9][0-9]*$/.test(rawNumber)) return null; + if (kind !== "pull" && kind !== "issues") return null; + + return { + host: host === "www.github.com" ? "github.com" : host, + owner, + repo, + number: Number(rawNumber), + pathKind: kind, + objectType: kind === "pull" ? "pull_request" : "issue", + }; +} + +function parseGitHubObject(object: { externalId: string; sanitizedCanonicalUrl: string | null }): GitHubObjectIdentity | null { + const match = /^([^/]+)\/([^/]+)#(pull|issues)\/([1-9][0-9]*)$/.exec(object.externalId); + if (!match) return null; + let host = "github.com"; + if (object.sanitizedCanonicalUrl) { + try { + const url = new URL(object.sanitizedCanonicalUrl); + if (isGitHubHost(url.hostname)) host = url.hostname === "www.github.com" ? "github.com" : url.hostname; + } catch { + return null; + } + } + return { + host, + owner: match[1]!, + repo: match[2]!, + pathKind: match[3] as "pull" | "issues", + number: Number(match[4]), + objectType: match[3] === "pull" ? "pull_request" : "issue", + }; +} + +function externalIdFor(identity: GitHubObjectIdentity) { + return `${identity.owner.toLowerCase()}/${identity.repo.toLowerCase()}#${identity.pathKind}/${identity.number}`; +} + +function displayTitleFor(identity: GitHubObjectIdentity) { + return `${identity.owner}/${identity.repo}#${identity.number}`; +} + +function displayKeyFor(identity: Pick) { + return identity.objectType === "pull_request" ? "GitHub Pull Request" : "GitHub Issue"; +} + +function retryAfterSeconds(response: Response) { + const retryAfter = response.headers.get("retry-after"); + if (retryAfter && /^[0-9]+$/.test(retryAfter)) return Number(retryAfter); + + const reset = response.headers.get("x-ratelimit-reset"); + if (reset && /^[0-9]+$/.test(reset)) { + return Math.max(1, Number(reset) - Math.floor(Date.now() / 1000)); + } + + return 300; +} + +function failureFromGitHubResponse(response: Response): ExternalObjectResolveResult | null { + if (response.status === 401) { + return { + ok: false, + liveness: "auth_required", + errorCode: "github_auth_required", + errorMessage: "GitHub authentication is required to refresh this object.", + retryAfterSeconds: retryAfterSeconds(response), + }; + } + + if (response.status === 403) { + const rateLimitRemaining = response.headers.get("x-ratelimit-remaining"); + if (rateLimitRemaining === "0") { + return { + ok: false, + liveness: "unreachable", + errorCode: "github_rate_limited", + errorMessage: "GitHub rate limit reached while refreshing this object.", + retryAfterSeconds: retryAfterSeconds(response), + }; + } + return { + ok: false, + liveness: "auth_required", + errorCode: "github_forbidden", + errorMessage: "GitHub rejected the configured credentials for this object.", + retryAfterSeconds: retryAfterSeconds(response), + }; + } + + if (response.status === 429 || response.status >= 500) { + return { + ok: false, + liveness: "unreachable", + errorCode: response.status === 429 ? "github_rate_limited" : "github_unreachable", + errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`, + retryAfterSeconds: retryAfterSeconds(response), + }; + } + + return null; +} + +function notFoundSnapshot(identity: GitHubObjectIdentity, etag: string | null): ExternalObjectResolverSnapshot { + return { + displayKey: displayKeyFor(identity), + iconKey: "github", + displayTitle: displayTitleFor(identity), + statusKey: "not_found", + statusLabel: "Not found", + statusIconKey: "archive", + statusCategory: "archived", + statusTone: "muted", + isTerminal: true, + etag, + ttlSeconds: GITHUB_OBJECT_TTL_SECONDS, + data: { + provider: "github", + owner: identity.owner, + repo: identity.repo, + number: identity.number, + notFound: true, + }, + }; +} + +function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record, etag: string | null): ExternalObjectResolverSnapshot { + const title = asString(body.title); + const state = asString(body.state) ?? "unknown"; + const draft = asBoolean(body.draft) ?? false; + const merged = (asBoolean(body.merged) ?? false) || Boolean(asString(body.merged_at)); + const authorLogin = asNestedString(body, "user", "login"); + const headRef = asNestedString(body, "head", "ref"); + const baseRef = asNestedString(body, "base", "ref"); + const reviewDecision = asString(body.review_decision); + + let statusKey = state; + let statusLabel = state === "open" ? "Open" : state === "closed" ? "Closed" : "Unknown"; + let statusCategory: ExternalObjectResolverSnapshot["statusCategory"] = state === "open" ? "open" : "unknown"; + let statusTone: ExternalObjectResolverSnapshot["statusTone"] = state === "open" ? "info" : "neutral"; + let isTerminal = false; + + if (merged) { + statusKey = "merged"; + statusLabel = "Merged"; + statusCategory = "succeeded"; + statusTone = "success"; + isTerminal = true; + } else if (state === "closed") { + statusKey = "closed"; + statusLabel = "Closed"; + statusCategory = "closed"; + statusTone = "muted"; + isTerminal = true; + } else if (draft) { + statusKey = "draft"; + statusLabel = "Draft"; + statusCategory = "waiting"; + statusTone = "warning"; + } + + return { + displayKey: displayKeyFor(identity), + iconKey: "github", + displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity), + statusKey, + statusLabel, + statusIconKey: merged + ? "git-merge" + : state === "closed" + ? "x-circle" + : draft + ? "clock" + : "git-pull-request", + statusCategory, + statusTone, + isTerminal, + remoteVersion: asString(body.updated_at), + etag, + ttlSeconds: GITHUB_OBJECT_TTL_SECONDS, + data: { + provider: "github", + owner: identity.owner, + repo: identity.repo, + number: identity.number, + state, + merged, + draft, + ...(authorLogin ? { authorLogin } : {}), + ...(headRef ? { headRef } : {}), + ...(baseRef ? { baseRef } : {}), + ...(reviewDecision ? { reviewDecision } : {}), + }, + }; +} + +function issueSnapshot(identity: GitHubObjectIdentity, body: Record, etag: string | null): ExternalObjectResolverSnapshot { + const title = asString(body.title); + const state = asString(body.state) ?? "unknown"; + const stateReason = asString(body.state_reason); + const authorLogin = asNestedString(body, "user", "login"); + const statusKey = state === "closed" && stateReason ? `closed_${stateReason}` : state; + const statusLabel = state === "closed" + ? stateReason + ? `Closed: ${stateReason.replace(/_/g, " ")}` + : "Closed" + : state === "open" + ? "Open" + : "Unknown"; + + return { + displayKey: displayKeyFor(identity), + iconKey: "github", + displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity), + statusKey, + statusLabel, + statusIconKey: state === "closed" ? "circle" : "circle-dot", + statusCategory: state === "open" ? "open" : state === "closed" ? "closed" : "unknown", + statusTone: state === "open" ? "info" : state === "closed" ? "muted" : "neutral", + isTerminal: state === "closed", + remoteVersion: asString(body.updated_at), + etag, + ttlSeconds: GITHUB_OBJECT_TTL_SECONDS, + data: { + provider: "github", + owner: identity.owner, + repo: identity.repo, + number: identity.number, + state, + ...(stateReason ? { stateReason } : {}), + ...(authorLogin ? { authorLogin } : {}), + }, + }; +} + +async function safeJson(response: Response) { + try { + return asRecord(await response.json()); + } catch { + return null; + } +} + +async function defaultTokenProvider(db: Db, companyId: string, secretNames: readonly string[]) { + const secrets = secretService(db); + for (const secretName of secretNames) { + const secret = await secrets.getByName(companyId, secretName); + if (!secret) continue; + const token = await secrets.resolveSecretValue(companyId, secret.id, "latest"); + const trimmed = token.trim(); + if (trimmed) return trimmed; + } + return null; +} + +export function createGitHubExternalObjectProvider( + db: Db, + opts: GitHubExternalObjectProviderOptions = {}, +): { detector: ExternalObjectDetector; resolvers: ExternalObjectResolver[] } { + const fetchImpl = opts.fetch ?? ghFetch; + const secretNames = opts.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES; + const tokenProvider = Object.prototype.hasOwnProperty.call(opts, "tokenProvider") && opts.tokenProvider !== undefined + ? opts.tokenProvider + : ((companyId: string) => defaultTokenProvider(db, companyId, secretNames)); + + const detector: ExternalObjectDetector = { + key: "github", + detect({ urls }): ExternalObjectDetection[] { + return urls.flatMap((canonical) => { + const identity = parseGitHubCanonicalUrl(canonical); + if (!identity) return []; + return [{ + canonical, + detectorKey: "github", + providerKey: "github", + objectType: identity.objectType, + externalId: externalIdFor(identity), + displayKey: displayKeyFor(identity), + iconKey: "github", + displayTitle: displayTitleFor(identity), + confidence: "exact", + }]; + }); + }, + }; + + function resolver(objectType: GitHubObjectIdentity["objectType"]): ExternalObjectResolver { + return { + providerKey: "github", + objectType, + async resolve({ companyId, object }) { + const identity = parseGitHubObject(object); + if (!identity || identity.objectType !== objectType) { + return { + ok: false, + liveness: "unreachable", + errorCode: "github_invalid_identity", + errorMessage: "GitHub object identity is invalid.", + retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS, + }; + } + + let token: string | null = null; + try { + token = typeof tokenProvider === "function" ? await tokenProvider(companyId) : tokenProvider; + } catch { + return { + ok: false, + liveness: "auth_required", + errorCode: "github_token_unavailable", + errorMessage: "Configured GitHub credentials could not be resolved.", + retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS, + }; + } + token = token?.trim() || null; + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "paperclip-external-object-resolver", + "x-github-api-version": "2022-11-28", + }; + if (token) headers.authorization = `Bearer ${token}`; + + const apiKind = objectType === "pull_request" ? "pulls" : "issues"; + const url = `${gitHubApiBase(identity.host)}/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repo)}/${apiKind}/${identity.number}`; + + let response: Response; + try { + response = await fetchImpl(url, { headers }); + } catch { + return { + ok: false, + liveness: "unreachable", + errorCode: "github_fetch_failed", + errorMessage: "GitHub could not be reached while refreshing this object.", + retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS, + }; + } + + const etag = response.headers.get("etag"); + if (response.status === 404) { + return { ok: true, snapshot: notFoundSnapshot(identity, etag) }; + } + + const failure = failureFromGitHubResponse(response); + if (failure) return failure; + if (!response.ok) { + return { + ok: false, + liveness: "unreachable", + errorCode: "github_unexpected_response", + errorMessage: `GitHub returned HTTP ${response.status} while refreshing this object.`, + retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS, + }; + } + + const body = await safeJson(response); + if (!body) { + return { + ok: false, + liveness: "unreachable", + errorCode: "github_invalid_response", + errorMessage: "GitHub returned an invalid object response.", + retryAfterSeconds: GITHUB_OBJECT_TTL_SECONDS, + }; + } + + return { + ok: true, + snapshot: objectType === "pull_request" + ? pullRequestSnapshot(identity, body, etag) + : issueSnapshot(identity, body, etag), + }; + }, + }; + } + + return { + detector, + resolvers: [resolver("pull_request"), resolver("issue")], + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 209e1c8d6a..e968098d72 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -33,6 +33,15 @@ export { resolveTaskWatchdogMutationScope, taskWatchdogScopeAllowsIssueMutation, } from "./task-watchdog-scope.js"; +export { + createExternalObjectDetectorRegistry, + createExternalObjectResolverRegistry, + externalObjectService, + type ExternalObjectDetector, + type ExternalObjectResolver, + type ExternalObjectResolveResult, + type ExternalObjectResolverSnapshot, +} from "./external-objects.js"; export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { approvalService } from "./approvals.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 03bf2c02e5..88656ec14d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -52,6 +52,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, + enableExternalObjects: parsed.data.enableExternalObjects ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -68,6 +69,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableCloudSync: false, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index f8b47866c3..9e01b696b5 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -99,6 +99,10 @@ const OPERATION_CAPABILITIES: Record = { "telemetry.track": ["telemetry.track"], "db.migrate": ["database.namespace.migrate"], "db.execute": ["database.namespace.write"], + "external.objects.detect": ["external.objects.detect"], + "external.objects.read": ["external.objects.read"], + "external.objects.write": ["external.objects.write"], + "external.objects.refresh": ["external.objects.refresh"], // Plugin state operations "plugin.state.get": ["plugin.state.read"], @@ -188,6 +192,7 @@ const FEATURE_CAPABILITIES: Record = { agents: "agents.managed", projects: "projects.managed", routines: "routines.managed", + objectReferences: "external.objects.detect", }; // --------------------------------------------------------------------------- @@ -452,6 +457,14 @@ export function pluginCapabilityValidator(): PluginCapabilityValidator { } } + if ((manifest.objectReferences?.length ?? 0) > 0) { + for (const requiredCap of ["external.objects.detect", "external.objects.read"] as const) { + if (!declared.has(requiredCap) && !allMissing.includes(requiredCap)) { + allMissing.push(requiredCap); + } + } + } + // Check UI slots → required capabilities const uiSlots = manifest.ui?.slots ?? []; if (uiSlots.length > 0) { diff --git a/ui/src/api/externalObjects.ts b/ui/src/api/externalObjects.ts new file mode 100644 index 0000000000..e728c82cd5 --- /dev/null +++ b/ui/src/api/externalObjects.ts @@ -0,0 +1,18 @@ +import type { ExternalObjectMentionGroup, ExternalObjectSummary } from "@paperclipai/shared"; +import { api } from "./client"; + +export const externalObjectsApi = { + listForIssue: (issueId: string) => + api.get(`/issues/${issueId}/external-objects`), + getIssueSummary: (issueId: string) => + api.get(`/issues/${issueId}/external-object-summary`), + getIssueSummaries: (companyId: string, issueIds: string[]) => + api.post<{ summaries: Record }>( + `/companies/${companyId}/issues/external-object-summaries`, + { issueIds }, + ), + refreshIssueObjects: (issueId: string, data?: { objectIds?: string[] }) => + api.post<{ refreshed: unknown[] }>(`/issues/${issueId}/external-objects/refresh`, data ?? {}), + getProjectSummary: (projectId: string) => + api.get(`/projects/${projectId}/external-object-summary`), +}; diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 7da3d5531d..1bee6e7589 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -6,6 +6,7 @@ export { companiesApi } from "./companies"; export { agentsApi } from "./agents"; export { projectsApi } from "./projects"; export { issuesApi } from "./issues"; +export { externalObjectsApi } from "./externalObjects"; export { routinesApi } from "./routines"; export { goalsApi } from "./goals"; export { approvalsApi } from "./approvals"; diff --git a/ui/src/components/CommentThread.external-references.test.tsx b/ui/src/components/CommentThread.external-references.test.tsx new file mode 100644 index 0000000000..3f65b6e658 --- /dev/null +++ b/ui/src/components/CommentThread.external-references.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Approval } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeProvider } from "../context/ThemeContext"; +import { CommentThread } from "./CommentThread"; + +vi.mock("./MarkdownEditor", () => ({ + MarkdownEditor: () => null, +})); + +vi.mock("./InlineEntitySelector", () => ({ + InlineEntitySelector: () => null, +})); + +vi.mock("./ApprovalCard", () => ({ + ApprovalCard: ({ approval }: { approval: Approval }) =>
{approval.type}
, +})); + +vi.mock("@/plugins/slots", () => ({ + PluginSlotOutlet: () => null, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { get: vi.fn() }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CommentThread external object decoration (integration)", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("decorates a resolved URL and leaves an unknown URL unchanged when rendered through the real MarkdownBody", () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const root = createRoot(container); + + act(() => { + root.render( + + + + {}} + /> + + + , + ); + }); + + const resolvedLink = container.querySelector( + 'a[href="https://github.com/example/repo/pull/77"]', + ); + expect(resolvedLink, "resolved URL should be wrapped by the external-link decorator").not.toBeNull(); + expect(resolvedLink?.getAttribute("data-external-link")).toBe("resolved"); + expect(resolvedLink?.getAttribute("data-external-status")).toBe("open"); + expect(resolvedLink?.classList.contains("paperclip-markdown-external-ref")).toBe(true); + + const unknownLink = container.querySelector( + 'a[href="https://elsewhere.example.com/page"]', + ); + expect(unknownLink, "unknown URL should still render as a plain link").not.toBeNull(); + expect(unknownLink?.getAttribute("data-external-link")).toBeNull(); + expect(unknownLink?.classList.contains("paperclip-markdown-external-ref")).toBe(false); + + act(() => { + root.unmount(); + }); + queryClient.clear(); + }); +}); diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index ed791baa2c..427d5f62ea 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -9,8 +9,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CommentThread } from "./CommentThread"; vi.mock("./MarkdownBody", () => ({ - MarkdownBody: ({ children, className }: { children: ReactNode; className?: string }) => ( -
{children}
+ MarkdownBody: ({ + children, + className, + externalReferences, + }: { + children: ReactNode; + className?: string; + externalReferences?: Record; + }) => ( +
+ {children} +
), })); @@ -395,4 +409,51 @@ describe("CommentThread", () => { root.unmount(); }); }); + + it("passes externalReferences to MarkdownBody for comment bodies", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + /> + , + ); + }); + + const commentRow = container.querySelector("#comment-comment-ref") as HTMLDivElement | null; + expect(commentRow).not.toBeNull(); + const markdownBody = commentRow?.querySelector('[data-testid="markdown-body"]') as HTMLElement | null; + expect(markdownBody?.getAttribute("data-external-reference-keys")) + .toContain("https://github.com/example/repo/pull/42"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cf0576abb1..9026b05106 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -14,7 +14,7 @@ import { ArrowRight, Check, Copy, Paperclip } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; -import { MarkdownBody } from "./MarkdownBody"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; import { ApprovalCard } from "./ApprovalCard"; @@ -105,6 +105,7 @@ interface CommentThreadProps { onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; composerDisabledReason?: string | null; + externalReferences?: MarkdownExternalReferenceMap; } const DRAFT_DEBOUNCE_MS = 800; @@ -324,6 +325,7 @@ function CommentCard({ voting = false, highlightCommentId, queued = false, + externalReferences, }: { comment: CommentWithRunMeta; agentMap?: Map; @@ -339,6 +341,7 @@ function CommentCard({ voting?: boolean; highlightCommentId?: string | null; queued?: boolean; + externalReferences?: MarkdownExternalReferenceMap; }) { const isHighlighted = highlightCommentId === comment.id; const isPending = comment.clientStatus === "pending"; @@ -412,7 +415,7 @@ function CommentCard({ {isDeleted ? (
Comment deleted
) : ( - {comment.body} + {comment.body} )} {companyId && !isPending && !isDeleted ? (
@@ -575,6 +578,7 @@ const TimelineList = memo(function TimelineList({ onVote, votingTargetId, highlightCommentId, + externalReferences, }: { timeline: TimelineItem[]; agentMap?: Map; @@ -597,6 +601,7 @@ const TimelineList = memo(function TimelineList({ ) => Promise; votingTargetId?: string | null; highlightCommentId?: string | null; + externalReferences?: MarkdownExternalReferenceMap; }) { if (timeline.length === 0) { return

No timeline entries yet.

; @@ -718,6 +723,7 @@ const TimelineList = memo(function TimelineList({ onVote={onVote ? (vote, options) => onVote(comment.id, vote, options) : undefined} voting={votingTargetId === comment.id} highlightCommentId={highlightCommentId} + externalReferences={externalReferences} /> ); })} @@ -756,6 +762,7 @@ export function CommentThread({ onInterruptQueued, interruptingQueuedRunId = null, composerDisabledReason = null, + externalReferences, }: CommentThreadProps) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -974,6 +981,7 @@ export function CommentThread({ votingTargetId={votingTargetId} highlightCommentId={highlightCommentId} feedbackTermsUrl={feedbackTermsUrl} + externalReferences={externalReferences} /> {liveRunSlot} @@ -1006,6 +1014,7 @@ export function CommentThread({ projectId={projectId} highlightCommentId={highlightCommentId} queued + externalReferences={externalReferences} /> ))}
diff --git a/ui/src/components/ExternalObjectPill.test.tsx b/ui/src/components/ExternalObjectPill.test.tsx new file mode 100644 index 0000000000..5899b11655 --- /dev/null +++ b/ui/src/components/ExternalObjectPill.test.tsx @@ -0,0 +1,170 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ExternalObjectPill } from "./ExternalObjectPill"; +import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary"; + +describe("ExternalObjectPill", () => { + it("renders a clickable anchor when a URL is provided", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('href="https://github.com/acme/web/pull/241"'); + expect(html).toContain('data-mention-kind="external-object"'); + expect(html).toContain('data-external-status="succeeded"'); + expect(html).toContain('data-external-liveness="fresh"'); + expect(html).toContain("×4"); + expect(html).toContain('aria-label="GitHub pull request — Succeeded: Add external refs"'); + }); + + it("falls back to a non-interactive span when no URL is supplied", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-mention-kind="external-object"'); + expect(html).not.toContain(" { + const stale = renderToStaticMarkup( + , + ); + expect(stale).toContain("opacity-70"); + expect(stale).toContain("[border-style:dashed]"); + + const auth = renderToStaticMarkup( + , + ); + expect(auth).toContain("[border-style:dashed]"); + }); + + it("does not show a source count when only a single mention is present", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain("×"); + }); + + it("uses the object link label, provider icon, and visible status when supplied", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Merged"); + expect(html).toContain("PR 241 - Merged"); + expect(html).not.toContain("acme/web#241"); + expect(html).toContain("text-violet-600"); + expect(html).not.toContain("Github Pull Request"); + expect(html).toContain('aria-label="GitHub pull request — Merged: acme/web#241: Add rich object presentation metadata"'); + }); +}); + +describe("ExternalObjectStatusSummary", () => { + it("hides itself when there are no external objects", () => { + const html = renderToStaticMarkup(); + expect(html).toBe(""); + }); + + it("hides itself when the highest severity is muted", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toBe(""); + }); + + it("shows the dominant-severity icon and count", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-external-status="failed"'); + expect(html).toContain('data-external-tone="danger"'); + expect(html).toContain(">3<"); + expect(html).toContain("aria-label=\"External objects: 3 failed, 2 succeeded, 5 total\""); + }); +}); diff --git a/ui/src/components/ExternalObjectPill.tsx b/ui/src/components/ExternalObjectPill.tsx new file mode 100644 index 0000000000..99617be12c --- /dev/null +++ b/ui/src/components/ExternalObjectPill.tsx @@ -0,0 +1,198 @@ +import type { ReactNode } from "react"; +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, +} from "@paperclipai/shared"; +import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon"; +import { + externalObjectStatusIcon, + externalObjectStatusIconDefault, + externalObjectLivenessOverlay, +} from "../lib/status-colors"; +import { + externalObjectCategoryLabel, + externalObjectLivenessLabel, + externalObjectIconForKey, + externalObjectProviderLabel, + externalObjectTypeLabel, +} from "../lib/external-objects"; +import { cn } from "../lib/utils"; + +export interface ExternalObjectPillData { + providerKey: string | null; + objectType: string | null; + displayKey?: string | null; + iconKey?: string | null; + statusCategory: ExternalObjectStatusCategory; + liveness: ExternalObjectLivenessState; + displayTitle?: string | null; + statusLabel?: string | null; + statusIconKey?: string | null; + url?: string | null; +} + +function githubObjectLabel(url: string | null | undefined): string | null { + if (!url) return null; + try { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") return null; + const [, owner, repo, kind, number] = parsed.pathname.split("/"); + if (!owner || !repo || !number) return null; + if (kind === "pull") return `PR ${number}`; + if (kind === "issues") return `Issue ${number}`; + return null; + } catch { + return null; + } +} + +function externalObjectValueLabel( + object: ExternalObjectPillData, + fallback: string, + statusLabel: string, +): string { + const githubLabel = object.providerKey === "github" ? githubObjectLabel(object.url) : null; + const base = githubLabel ?? object.displayTitle?.trim() ?? fallback; + return statusLabel ? `${base} - ${statusLabel}` : base; +} + +function isMergedExternalObject(object: ExternalObjectPillData, statusLabel: string): boolean { + return object.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged"; +} + +function externalObjectPillTone(object: ExternalObjectPillData, statusLabel: string): string { + if (isMergedExternalObject(object, statusLabel)) { + return "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400"; + } + return externalObjectStatusIcon[object.statusCategory] ?? externalObjectStatusIconDefault; +} + +function externalObjectStatusIconKey( + object: ExternalObjectPillData, + statusLabel: string, +): string | null | undefined { + if (isMergedExternalObject(object, statusLabel)) return object.statusIconKey ?? "git-merge"; + return object.statusIconKey; +} + +interface ExternalObjectPillProps { + object: ExternalObjectPillData; + /** Optional external mention count (renders as `×N` superscript when > 1). */ + sourceCount?: number; + /** Optional source-mention summary used as the pill's `title` attribute. */ + sourceSummary?: string | null; + className?: string; + /** Optional rendered label override. Defaults to `provider object-type`. */ + children?: ReactNode; + /** + * If true the pill renders without a hover treatment (used inside + * non-interactive contexts like the property panel). Defaults to false. + */ + inert?: boolean; + /** + * Hide the provider icon when the surrounding UI already names the provider. + * The status icon still renders as the single state glyph inside the pill. + */ + showProviderIcon?: boolean; +} + +/** + * External-object equivalent of `IssueReferencePill`. Same `paperclip-mention-chip` + * base so external references feel native to readers (Jakob's Law). + */ +export function ExternalObjectPill({ + object, + sourceCount, + sourceSummary, + className, + children, + inert, + showProviderIcon = true, +}: ExternalObjectPillProps) { + const overlay = externalObjectLivenessOverlay[object.liveness] ?? ""; + const providerLabel = externalObjectProviderLabel(object.providerKey); + const typeLabel = externalObjectTypeLabel(object.objectType); + const displayKey = object.displayKey?.trim() || `${providerLabel} ${typeLabel}`; + const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory); + const tone = externalObjectPillTone(object, statusLabel); + const valueLabel = externalObjectValueLabel(object, displayKey, statusLabel); + const statusIconKey = externalObjectStatusIconKey(object, statusLabel); + const livenessLabel = externalObjectLivenessLabel(object.liveness); + const ProviderIcon = externalObjectIconForKey(object.iconKey); + const ariaLabel = `${providerLabel} ${typeLabel} — ${statusLabel}${ + object.liveness === "fresh" || object.liveness === "unknown" ? "" : ` (${livenessLabel})` + }${object.displayTitle ? `: ${object.displayTitle}` : ""}`; + + const interactive = !inert && Boolean(object.url); + const classNames = cn( + "paperclip-mention-chip paperclip-mention-chip--external-object", + "inline-flex max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline", + // Tone is applied as text classes only — the border style comes from the + // overlay (dashed for stale/auth/unreachable). + tone.split(" ").filter((c) => c.startsWith("text-")).join(" "), + overlay, + interactive + && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring", + className, + ); + const titleAttr = sourceSummary + ? `${object.displayTitle ?? displayKey} — ${sourceSummary}` + : object.displayTitle ?? displayKey; + const labelText = children ?? ( + <> + + {valueLabel} + + ); + const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? ( + ×{sourceCount} + ) : null; + const innerContent = ( + <> + {showProviderIcon && ProviderIcon ? ( + + {innerContent} + + ); + } + + return ( + + {innerContent} + + ); +} diff --git a/ui/src/components/ExternalObjectStatusIcon.tsx b/ui/src/components/ExternalObjectStatusIcon.tsx new file mode 100644 index 0000000000..a6a42de052 --- /dev/null +++ b/ui/src/components/ExternalObjectStatusIcon.tsx @@ -0,0 +1,94 @@ +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, +} from "@paperclipai/shared"; +import { Clock } from "lucide-react"; +import { + externalObjectStatusIcon, + externalObjectStatusIconDefault, +} from "../lib/status-colors"; +import { + externalObjectCategoryLabel, + externalObjectIconForCategory, + externalObjectIconForKey, + externalObjectLivenessLabel, +} from "../lib/external-objects"; +import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; +import { cn } from "../lib/utils"; + +interface ExternalObjectStatusIconProps { + category: ExternalObjectStatusCategory; + liveness: ExternalObjectLivenessState; + statusIconKey?: string | null; + /** Optional override label used in `aria-label` (e.g. provider-specific copy). */ + label?: string | null; + className?: string; + /** Tailwind size class — defaults to `h-3.5 w-3.5` (compact pill use). */ + sizeClassName?: string; + /** + * When true the icon renders inline at 12 px with the `mr-1`/`align` + * adjustments used by `MarkdownIssueLink`. Used by the markdown decorator. + */ + inline?: boolean; +} + +/** + * Pure presentational icon for an external object's status. Combines: + * + * - A category icon (lucide) from `externalObjectIconForCategory`. + * - Tone classes (text + border) from `externalObjectStatusIcon`. + * - An overlaid clock micro-mark when liveness is `stale`. + * - Reduced-motion-aware spinner for the `running` category. + * + * Never mounts plugin React; the host is the sole renderer of identity glyphs. + */ +export function ExternalObjectStatusIcon({ + category, + liveness, + statusIconKey, + label, + className, + sizeClassName = "h-3.5 w-3.5", + inline = false, +}: ExternalObjectStatusIconProps) { + const reducedMotion = usePrefersReducedMotion(); + const Icon = externalObjectIconForKey(statusIconKey) ?? externalObjectIconForCategory(category); + const tone = statusIconKey === "git-merge" + ? "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400" + : externalObjectStatusIcon[category] ?? externalObjectStatusIconDefault; + const livenessSuffix = liveness === "fresh" || liveness === "unknown" + ? "" + : ` (${externalObjectLivenessLabel(liveness)})`; + const ariaLabel = `${label ?? externalObjectCategoryLabel(category)}${livenessSuffix}`; + + // The clock overlay needs a positioned wrapper. Inline mode keeps the icon + // tight to the surrounding text; pill mode expects to size by sizeClassName. + const wrapperBase = inline + ? "relative mr-1 inline-flex shrink-0 align-[-0.125em]" + : "relative inline-flex shrink-0"; + const iconSize = inline ? "h-3 w-3" : sizeClassName; + const isSpinner = category === "running"; + const animateClass = isSpinner && !reducedMotion ? "animate-spin" : ""; + + return ( + + + ); +} diff --git a/ui/src/components/ExternalObjectStatusSummary.tsx b/ui/src/components/ExternalObjectStatusSummary.tsx new file mode 100644 index 0000000000..b3400fa052 --- /dev/null +++ b/ui/src/components/ExternalObjectStatusSummary.tsx @@ -0,0 +1,83 @@ +import type { ExternalObjectSummary } from "@paperclipai/shared"; +import { + dominantExternalObjectTone, + externalObjectCategoryLabel, + externalObjectDominantCount, + externalObjectIconForCategory, + externalObjectIconForKey, +} from "../lib/external-objects"; +import { externalObjectStatusBadge, externalObjectStatusBadgeDefault } from "../lib/status-colors"; +import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; +import { cn } from "../lib/utils"; + +interface ExternalObjectStatusSummaryProps { + summary: ExternalObjectSummary | null | undefined; + /** Compact mode trims everything down to the icon + count, no label text. */ + compact?: boolean; + className?: string; +} + +function dominantCategory(summary: ExternalObjectSummary): string { + // Prefer the first object that matches the highestSeverity tone, since the + // server has already ranked them server-side. + const match = summary.objects.find((object) => object.statusTone === summary.highestSeverity); + return match?.statusCategory ?? "unknown"; +} + +function dominantObject(summary: ExternalObjectSummary) { + return summary.objects.find((object) => object.statusTone === summary.highestSeverity) ?? null; +} + +function buildBreakdownTitle(summary: ExternalObjectSummary): string { + const parts: string[] = []; + for (const [category, count] of Object.entries(summary.byStatusCategory)) { + if (!count) continue; + parts.push(`${count} ${externalObjectCategoryLabel(category).toLowerCase()}`); + } + if (summary.staleCount > 0) parts.push(`${summary.staleCount} stale`); + parts.push(`${summary.total} total`); + return `External objects: ${parts.join(", ")}`; +} + +/** + * Compact rollup marker used by sidebar projects and issue list rows. Renders + * the dominant severity icon plus a count badge. Hidden when there are zero + * external objects or every object is in a muted tone. + */ +export function ExternalObjectStatusSummary({ + summary, + compact, + className, +}: ExternalObjectStatusSummaryProps) { + const reducedMotion = usePrefersReducedMotion(); + const tone = dominantExternalObjectTone(summary); + const total = summary?.total ?? 0; + if (!summary || total === 0 || !tone) return null; + + const object = dominantObject(summary); + const category = object?.statusCategory ?? dominantCategory(summary); + const Icon = externalObjectIconForKey(object?.statusIconKey) ?? externalObjectIconForCategory(category); + const badgeClass = externalObjectStatusBadge[category] ?? externalObjectStatusBadgeDefault; + const dominantCount = externalObjectDominantCount(summary); + const title = buildBreakdownTitle(summary); + const animateClass = category === "running" && !reducedMotion ? "animate-spin" : ""; + + return ( + + + ); +} diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 192e8260d8..094566243a 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { cn } from "../lib/utils"; -import { MarkdownBody } from "./MarkdownBody"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; import { FoldCurtain } from "./FoldCurtain"; @@ -19,6 +19,11 @@ interface InlineEditorProps { nullable?: boolean; /** When true, long display-mode markdown is clipped with a fade curtain that expands on click. */ foldable?: boolean; + /** + * Optional host-resolved external object metadata. Forwarded to the read-mode + * `MarkdownBody` so resolved URLs render with the inline status icon prefix. + */ + externalReferences?: MarkdownExternalReferenceMap; } /** Shared padding so display and edit modes occupy the exact same box. */ @@ -55,6 +60,7 @@ export function InlineEditor({ onDropFile, mentions, foldable = false, + externalReferences, }: InlineEditorProps) { const [editing, setEditing] = useState(false); const [multilineEditing, setMultilineEditing] = useState(false); @@ -288,12 +294,18 @@ export function InlineEditor({ > {foldable ? ( - + {previewValue} ) : ( - + {previewValue} )} diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index aa215ac4fc..48b4d92244 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -101,7 +101,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { MarkdownBody } from "./MarkdownBody"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; import { WorkspaceFileMarkdownBody } from "./WorkspaceFileMarkdownBody"; import { MarkdownEditor, type MentionOption, type MarkdownEditorRef } from "./MarkdownEditor"; import { Identity } from "./Identity"; @@ -217,6 +217,7 @@ interface IssueChatMessageContext { onUploadImage?: (file: File) => Promise; issueStatus?: string; successfulRunHandoff?: SuccessfulRunHandoffState | null; + externalReferences?: MarkdownExternalReferenceMap; } const IssueChatCtx = createContext({ @@ -450,6 +451,7 @@ interface IssueChatThreadProps { * comment is in the loaded set before we scroll to it. */ onRefreshLatestComments?: () => Promise | void; + externalReferences?: MarkdownExternalReferenceMap; } type IssueChatErrorBoundaryProps = { @@ -457,6 +459,7 @@ type IssueChatErrorBoundaryProps = { messages: readonly ThreadMessage[]; emptyMessage: string; variant: "full" | "embedded"; + externalReferences?: MarkdownExternalReferenceMap; children: ReactNode; }; @@ -491,6 +494,7 @@ class IssueChatErrorBoundary extends Component ); } @@ -555,10 +559,12 @@ function IssueChatFallbackThread({ messages, emptyMessage, variant, + externalReferences, }: { messages: readonly ThreadMessage[]; emptyMessage: string; variant: "full" | "embedded"; + externalReferences?: MarkdownExternalReferenceMap; }) { return (
@@ -599,7 +605,9 @@ function IssueChatFallbackThread({
{lines.length > 0 ? lines.map((line, index) => ( - {line} + + {line} + )) : (

No message content.

)} @@ -705,7 +713,7 @@ function commentDateLabel(date: Date | string | undefined): string { } const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAccent }: { text: string; recessed?: boolean; onAccent?: boolean }) { - const { onImageClick } = useContext(IssueChatCtx); + const { onImageClick, externalReferences } = useContext(IssueChatCtx); if (isSuccessfulRunHandoffComment(text)) { return ; } @@ -717,6 +725,7 @@ const IssueChatTextPart = memo(function IssueChatTextPart({ text, recessed, onAc style={recessed ? { opacity: 0.55 } : undefined} softBreaks onImageClick={onImageClick} + externalReferences={externalReferences} > {text} @@ -2113,6 +2122,7 @@ function ExpiredRequestConfirmationActivity({ onRejectInteraction, onCancelInteraction, onUploadImage, + externalReferences, } = useContext(IssueChatCtx); const [expanded, setExpanded] = useState(false); const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId); @@ -2193,6 +2203,7 @@ function ExpiredRequestConfirmationActivity({ onRejectInteraction={onRejectInteraction} onCancelInteraction={onCancelInteraction} onUploadImage={onUploadImage} + externalReferences={externalReferences} />
) : null} @@ -2620,6 +2631,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { onSubmitInteractionAnswers, onCancelInteraction, onUploadImage, + externalReferences, } = useContext(IssueChatCtx); const custom = message.metadata.custom as Record; const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined; @@ -2677,6 +2689,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { onSubmitInteractionAnswers={onSubmitInteractionAnswers} onCancelInteraction={onCancelInteraction} onUploadImage={onUploadImage} + externalReferences={externalReferences} /> @@ -4094,6 +4107,7 @@ export function IssueChatThread({ assigneeUserId = null, onResumeFromBacklog, resumeFromBacklogPending = false, + externalReferences, }: IssueChatThreadProps) { const location = useLocation(); const lastScrolledHashRef = useRef(null); @@ -4618,6 +4632,7 @@ export function IssueChatThread({ onUploadImage: stableOnUploadImage, issueStatus, successfulRunHandoff, + externalReferences, }), [ feedbackDataSharingPreference, @@ -4643,6 +4658,7 @@ export function IssueChatThread({ stableOnUploadImage, issueStatus, successfulRunHandoff, + externalReferences, ], ); @@ -4680,6 +4696,7 @@ export function IssueChatThread({ messages={messages} emptyMessage={resolvedEmptyMessage} variant={variant} + externalReferences={externalReferences} >
{expanded ? (
- + {document.body}
diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 0ce5cb6627..4884beea5d 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -43,8 +43,22 @@ vi.mock("@/lib/router", () => ({ })); vi.mock("./MarkdownBody", () => ({ - MarkdownBody: ({ children, className }: { children: string; className?: string }) => ( -
{children}
+ MarkdownBody: ({ + children, + className, + externalReferences, + }: { + children: string; + className?: string; + externalReferences?: Record; + }) => ( +
+ {children} +
), })); @@ -684,6 +698,61 @@ describe("IssueDocumentsSection", () => { queryClient.clear(); }); + it("forwards externalReferences to the rendered document body so URL decoration applies", async () => { + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([ + createIssueDocument({ + body: "Linked work: https://github.com/example/repo/pull/99", + }), + ]); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const markdownBodies = Array.from( + container.querySelectorAll('[data-testid="markdown-body"]'), + ) as HTMLElement[]; + expect(markdownBodies.length).toBeGreaterThan(0); + const rendered = markdownBodies.find((element) => + (element.textContent ?? "").includes("Linked work"), + ); + expect(rendered?.getAttribute("data-external-reference-keys")) + .toContain("https://github.com/example/repo/pull/99"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + it("wraps the documents header actions so mobile layouts do not overflow", async () => { const issue = createIssue(); const root = createRoot(container); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 4b329cfeab..ffa59eb3fc 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -20,7 +20,7 @@ import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { FoldCurtain } from "./FoldCurtain"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations"; -import { MarkdownBody } from "./MarkdownBody"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; import { MarkdownEditor, type MentionOption } from "./MarkdownEditor"; import { OutputFeedbackButtons } from "./OutputFeedbackButtons"; import { Button } from "@/components/ui/button"; @@ -75,10 +75,16 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) { window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys)); } -function renderFoldableBody(body: string, className?: string) { +function renderFoldableBody( + body: string, + className?: string, + externalReferences?: MarkdownExternalReferenceMap, +) { return ( - {body} + + {body} + ); } @@ -161,6 +167,7 @@ export function IssueDocumentsSection({ defaultAnnotationPanelOpenKeys, defaultAnnotationFocusedThreadIds, forceEditDocumentKey, + externalReferences, }: { issue: Issue; canDeleteDocuments: boolean; @@ -187,6 +194,7 @@ export function IssueDocumentsSection({ defaultAnnotationFocusedThreadIds?: Readonly>; /** Force a doc into edit mode on mount (Storybook-only). */ forceEditDocumentKey?: string | null; + externalReferences?: MarkdownExternalReferenceMap; }) { const queryClient = useQueryClient(); const location = useLocation(); @@ -755,6 +763,7 @@ export function IssueDocumentsSection({ }, [autosaveState, commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]); const documentBodyShellClassName = "mt-3"; + const documentBodyPaddingClassName = ""; const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-[220px] text-[15px] leading-7"; const toggleFoldedDocument = (key: string) => { setFoldedDocumentKeys((current) => @@ -878,7 +887,9 @@ export function IssueDocumentsSection({ PLAN
- {renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName)} +
+ {renderFoldableBody(issue.legacyPlanDocument.body, documentBodyContentClassName, externalReferences)} +
) : null} @@ -1197,7 +1208,7 @@ export function IssueDocumentsSection({ {!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (

{activeConflict.serverDocument.title}

) : null} - {renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7")} + {renderFoldableBody(activeConflict.serverDocument.body, "text-[14px] leading-7", externalReferences)} )} @@ -1235,7 +1246,7 @@ export function IssueDocumentsSection({ defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]} > {isHistoricalPreview ? ( - renderFoldableBody(displayedBody, documentBodyContentClassName) + renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences) ) : activeDraft ? ( void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })} /> ) : ( - renderFoldableBody(displayedBody, documentBodyContentClassName) + renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences) )} diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 19a844ad2c..40de541730 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -9,6 +9,8 @@ import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; import { defaultIssueFilterState, + externalObjectFilterLabel, + externalObjectFilterOrder, issueFilterArraysEqual, issueFilterLabel, issuePriorityOrder, @@ -17,6 +19,8 @@ import { toggleIssueFilterValue, type IssueFilterState, } from "../lib/issue-filters"; +import { externalObjectIconForCategory } from "../lib/external-objects"; +import { externalObjectStatusIcon } from "../lib/status-colors"; import { formatAssigneeUserLabel } from "../lib/assignees"; type AgentOption = { @@ -55,6 +59,7 @@ export function IssueFiltersPopover({ projects, labels, currentUserId, + enableExternalObjectFilters = true, enableRoutineVisibilityFilter = false, buttonVariant = "ghost", iconOnly = false, @@ -68,6 +73,7 @@ export function IssueFiltersPopover({ projects?: ProjectOption[]; labels?: LabelOption[]; currentUserId?: string | null; + enableExternalObjectFilters?: boolean; enableRoutineVisibilityFilter?: boolean; buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; @@ -344,6 +350,39 @@ export function IssueFiltersPopover({ ) : null} + {enableExternalObjectFilters ? ( +
+ External object status +
+ {externalObjectFilterOrder.map((value) => { + const iconCategory = value === "failed" ? "failed" + : value === "waiting" ? "waiting" + : value === "running" ? "running" + : value === "auth_required" ? "auth_required" + : value === "unreachable" ? "unreachable" + : value === "stale" ? "unknown" + : "closed"; + const Icon = externalObjectIconForCategory(iconCategory); + const tone = externalObjectStatusIcon[iconCategory] ?? ""; + const textTone = tone.split(" ").filter((c) => c.startsWith("text-")).join(" "); + return ( + + ); + })} +
+
+ ) : null} +
Visibility
) : null} @@ -1173,6 +1176,7 @@ function RequestConfirmationCard({ onAcceptInteraction, onRejectInteraction, onUploadImage, + externalReferences, }: { interaction: RequestConfirmationInteraction; isPlan?: boolean; @@ -1184,6 +1188,7 @@ function RequestConfirmationCard({ reason?: string, ) => Promise | void; onUploadImage?: (file: File) => Promise; + externalReferences?: MarkdownExternalReferenceMap; }) { const [rejecting, setRejecting] = useState(false); const [working, setWorking] = useState<"accept" | "reject" | null>(null); @@ -1283,7 +1288,7 @@ function RequestConfirmationCard({ {interaction.payload.detailsMarkdown ? (
- {interaction.payload.detailsMarkdown} + {interaction.payload.detailsMarkdown}
) : null} Promise | void; + externalReferences?: MarkdownExternalReferenceMap; }) { const options = interaction.payload.options; const optionIds = useMemo(() => options.map((option) => option.id), [options]); @@ -1721,7 +1728,7 @@ function RequestCheckboxConfirmationCard({
{interaction.payload.prompt}
{interaction.payload.detailsMarkdown ? (
- {interaction.payload.detailsMarkdown} + {interaction.payload.detailsMarkdown}
) : null} ) : interaction.kind === "request_checkbox_confirmation" ? ( ) : ( )} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index cb07cdfc24..b62d8a6dda 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -42,6 +42,10 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); +const mockExternalObjectsApi = vi.hoisted(() => ({ + getIssueSummaries: vi.fn(), +})); + vi.mock("../context/CompanyContext", () => ({ useCompany: () => companyState, })); @@ -87,6 +91,10 @@ vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); +vi.mock("../api/externalObjects", () => ({ + externalObjectsApi: mockExternalObjectsApi, +})); + vi.mock("./IssueRow", () => ({ IssueRow: ({ issue, @@ -97,6 +105,7 @@ vi.mock("./IssueRow", () => ({ checklistCurrentStep, checklistDependencyChips, checklistRowId, + externalObjectSummary, }: { issue: Issue; desktopMetaLeading?: ReactNode; @@ -106,6 +115,7 @@ vi.mock("./IssueRow", () => ({ checklistCurrentStep?: boolean; checklistDependencyChips?: ReactNode; checklistRowId?: string; + externalObjectSummary?: { total: number } | null; }) => (
({ data-title-class={titleClassName ?? undefined} > {issue.title} + {externalObjectSummary ? ( + {externalObjectSummary.total} + ) : null} {desktopMetaLeading} {desktopTrailing} {checklistDependencyChips} @@ -286,6 +299,7 @@ describe("IssuesList", () => { mockExecutionWorkspacesApi.list.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockReset(); mockInstanceSettingsApi.getExperimental.mockReset(); + mockExternalObjectsApi.getIssueSummaries.mockReset(); mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: null, session: null }); @@ -293,8 +307,12 @@ describe("IssuesList", () => { mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] }); mockExecutionWorkspacesApi.list.mockResolvedValue([]); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableExternalObjects: false, + }); setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 }); + mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} }); localStorage.clear(); }); @@ -303,6 +321,130 @@ describe("IssuesList", () => { container.remove(); }); + it("forwards external-object summaries into issue rows", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableExternalObjects: true, + }); + mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ + summaries: { + "issue-1": { + total: 2, + byStatusCategory: { failed: 1, succeeded: 1 }, + byLiveness: { fresh: 2 }, + highestSeverity: "danger", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }, + }, + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith("company-1", ["issue-1"]); + expect(container.querySelector("[data-testid='external-object-summary']")?.textContent).toBe("2"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("does not load external-object summaries when the experimental flag is disabled", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled(); + expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull(); + }); + expect(mockExternalObjectsApi.getIssueSummaries).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + + it("filters issue rows by external-object status summaries", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableExternalObjects: true, + }); + const failedIssue = createIssue({ id: "issue-failed", identifier: "PAP-10", title: "Failed external object" }); + const freshIssue = createIssue({ id: "issue-fresh", identifier: "PAP-11", title: "Fresh external object" }); + const noObjectIssue = createIssue({ id: "issue-none", identifier: "PAP-12", title: "No external object" }); + localStorage.setItem("paperclip:test-issues:company-1", JSON.stringify({ externalObjectStatuses: ["failed"] })); + mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ + summaries: { + "issue-failed": { + total: 1, + byStatusCategory: { failed: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "danger", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }, + "issue-fresh": { + total: 1, + byStatusCategory: { succeeded: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "success", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }, + }, + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockExternalObjectsApi.getIssueSummaries).toHaveBeenCalledWith( + "company-1", + ["issue-failed", "issue-fresh", "issue-none"], + ); + expect(container.textContent).toContain("Failed external object"); + expect(container.textContent).not.toContain("Fresh external object"); + expect(container.textContent).not.toContain("No external object"); + }); + + act(() => { + root.unmount(); + }); + }); + it("renders server search results instead of filtering the full issue list locally", async () => { const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" }); const serverIssue = createIssue({ id: "issue-server", identifier: "PAP-2", title: "Server result" }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 833845afaf..81d4af908e 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -9,6 +9,7 @@ import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; +import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects"; import { shouldBlurPageSearchOnEnter, shouldBlurPageSearchOnEscape, @@ -642,7 +643,9 @@ export function IssuesList({ retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const experimentalSettingsLoaded = experimentalSettings !== undefined; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; + const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; @@ -692,6 +695,11 @@ export function IssuesList({ }); }, [scopedKey]); + useEffect(() => { + if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return; + updateView({ externalObjectStatuses: [] }); + }, [experimentalSettingsLoaded, externalObjectsEnabled, updateView, viewState.externalObjectStatuses.length]); + // Prune stale IDs from collapsedParents whenever the issue list changes. // Deleted or reassigned issues leave orphan IDs in localStorage; this keeps // the stored array bounded to only current parent IDs. @@ -967,32 +975,58 @@ export function IssuesList({ [boardIssueQueries, searchWithinLoadedIssues, viewState.viewMode], ); - const filtered = useMemo(() => { + const sourceIssues = useMemo(() => { const useRemoteSearch = normalizedIssueSearch.length > 0 && !searchWithinLoadedIssues; - const sourceIssues = boardIssues ?? (useRemoteSearch ? searchedIssues : issues); - const searchScopedIssues = normalizedIssueSearch.length > 0 && searchWithinLoadedIssues + return boardIssues ?? (useRemoteSearch ? searchedIssues : issues); + }, [boardIssues, issues, normalizedIssueSearch, searchedIssues, searchWithinLoadedIssues]); + + const searchScopedIssues = useMemo( + () => normalizedIssueSearch.length > 0 && searchWithinLoadedIssues ? sourceIssues.filter((issue) => issueMatchesLocalSearch(issue, normalizedIssueSearch)) - : sourceIssues; + : sourceIssues, + [normalizedIssueSearch, searchWithinLoadedIssues, sourceIssues], + ); + const hasExternalObjectStatusFilters = viewState.externalObjectStatuses.length > 0; + const issueIdsForExternalObjectSummaries = useMemo( + () => (viewState.viewMode === "list" || hasExternalObjectStatusFilters + ? searchScopedIssues.map((issue) => issue.id) + : []), + [hasExternalObjectStatusFilters, searchScopedIssues, viewState.viewMode], + ); + const { + summaries: externalObjectSummaryByIssueId, + isLoading: externalObjectSummariesLoading, + isReady: externalObjectSummariesReady, + } = useIssueExternalObjectSummaries( + selectedCompanyId, + issueIdsForExternalObjectSummaries, + ); + const issueFilterContext = useMemo(() => ({ + ...issueFilterWorkspaceContext, + externalObjectSummaryByIssueId, + externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, + }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId, issueFilterWorkspaceContext]); + const externalObjectFilterLoading = hasExternalObjectStatusFilters + && externalObjectSummariesLoading + && !externalObjectSummariesReady; + + const filtered = useMemo(() => { const filteredByControls = applyIssueFilters( searchScopedIssues, viewState, currentUserId, enableRoutineVisibilityFilter, liveIssueIds, - issueFilterWorkspaceContext, + issueFilterContext, ); return sortIssues(filteredByControls, viewState); }, [ - boardIssues, - issues, - searchedIssues, - searchWithinLoadedIssues, + searchScopedIssues, viewState, - normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter, liveIssueIds, - issueFilterWorkspaceContext, + issueFilterContext, ]); const progressSummary = useMemo( @@ -1479,6 +1513,7 @@ export function IssuesList({ projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} + enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} iconOnly workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} @@ -1565,7 +1600,7 @@ export function IssuesList({
- {isLoading && } + {(isLoading || externalObjectFilterLoading) && } {error &&

{error.message}

} {!searchWithinLoadedIssues && normalizedIssueSearch.length > 0 && searchedIssues.length === ISSUE_SEARCH_RESULT_LIMIT && (

@@ -1577,7 +1612,7 @@ export function IssuesList({ Some board columns are showing up to {ISSUE_BOARD_COLUMN_RESULT_LIMIT} tasks. Refine filters or search to reveal the rest.

)} - {!isLoading && filtered.length === 0 && viewState.viewMode === "list" && ( + {!isLoading && !externalObjectFilterLoading && filtered.length === 0 && viewState.viewMode === "list" && ( {hasChildren && !isExpanded ? ( diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 60052cc094..87814f7d8c 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,4 +1,4 @@ -import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; +import { isValidElement, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; @@ -15,6 +15,36 @@ import { parseWorkspaceFileHref, remarkWorkspaceFileRefs, WORKSPACE_FILE_HREF_PR import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; import { StatusIcon } from "./StatusIcon"; import { WorkspaceFileLink } from "./WorkspaceFileLink"; +import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon"; +import { + externalObjectCategoryLabel, + externalObjectLivenessLabel, + externalObjectProviderLabel, +} from "../lib/external-objects"; +import { normalizeExternalObjectHref } from "../lib/external-object-href"; +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, +} from "@paperclipai/shared"; + +/** + * Host-resolved external-object metadata for inline markdown decoration. + * The renderer only consumes the host normalized fields here — plugin React + * is never mounted inline (Phase 1B security review). + */ +export interface MarkdownExternalReference { + providerKey: string | null; + objectType: string | null; + displayKey?: string | null; + iconKey?: string | null; + statusCategory: ExternalObjectStatusCategory; + liveness: ExternalObjectLivenessState; + statusLabel?: string | null; + statusIconKey?: string | null; + displayTitle?: string | null; +} + +export type MarkdownExternalReferenceMap = Record; interface MarkdownBodyProps { children: string; @@ -28,6 +58,12 @@ interface MarkdownBodyProps { wikiLinkRoot?: string; /** Optional href resolver for wikilinks. Return null to leave a token as plain text. */ resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined; + /** + * Optional map of `normalizeExternalObjectHref(href)` → host-resolved metadata. + * Hrefs in the markdown that resolve to one of these keys get the inline + * status icon prefix used by §2 of the UX spec. + */ + externalReferences?: MarkdownExternalReferenceMap; /** Optional resolver for relative image paths (e.g. within export packages) */ resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ @@ -72,6 +108,51 @@ function MarkdownIssueLink({ ); } +function MarkdownExternalLink({ + href, + reference, + children, +}: { + href: string; + reference: MarkdownExternalReference; + children: ReactNode; +}) { + const provider = externalObjectProviderLabel(reference.providerKey); + const displayKey = reference.displayKey?.trim() || provider; + const statusLabel = reference.statusLabel ?? externalObjectCategoryLabel(reference.statusCategory); + const livenessLabel = externalObjectLivenessLabel(reference.liveness); + const livenessSuffix = reference.liveness === "fresh" || reference.liveness === "unknown" + ? "" + : ` (${livenessLabel})`; + const titleParts = [ + reference.displayTitle ?? `${displayKey} ${statusLabel}`, + `${displayKey} — ${statusLabel}${livenessSuffix}`, + ]; + const title = titleParts.filter(Boolean).join(" · "); + return ( + + + {children} + + ); +} + function loadMermaid() { if (!mermaidLoaderPromise) { mermaidLoaderPromise = import("mermaid").then((module) => module.default); @@ -572,6 +653,7 @@ export function MarkdownBody({ enableWikiLinks = false, wikiLinkRoot, resolveWikiLinkHref, + externalReferences, resolveImageSrc, onImageClick, linkWorkspaceFileRefs = false, @@ -584,6 +666,15 @@ export function MarkdownBody({ const knownPrefixes = company?.companies.length ? company.companies.map((c) => c.issuePrefix) : undefined; + const externalReferenceLookup = useMemo(() => { + if (!externalReferences) return null; + const lookup: MarkdownExternalReferenceMap = {}; + for (const [key, value] of Object.entries(externalReferences)) { + const normalized = normalizeExternalObjectHref(key) ?? key; + lookup[normalized] = value; + } + return lookup; + }, [externalReferences]); const remarkPlugins: NonNullable = [remarkGfm]; if (enableWikiLinks) { remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref })); @@ -706,6 +797,17 @@ export function MarkdownBody({ ); } + const externalReference = href && externalReferenceLookup + ? externalReferenceLookup[normalizeExternalObjectHref(href) ?? ""] ?? null + : null; + if (externalReference && href) { + return ( + + {linkChildren} + + ); + } + const isGitHubLink = isGitHubUrl(href); const isExternal = isExternalHttpUrl(href); const leadingIcon = isGitHubLink ? ( diff --git a/ui/src/components/MarkdownExternalLink.test.tsx b/ui/src/components/MarkdownExternalLink.test.tsx new file mode 100644 index 0000000000..173b1c46c3 --- /dev/null +++ b/ui/src/components/MarkdownExternalLink.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment node + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ThemeProvider } from "../context/ThemeContext"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + ...props + }: { children: ReactNode; to: string } & React.ComponentProps<"a">) => ( + {children} + ), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { get: vi.fn() }, +})); + +function render(children: string, externalReferences?: MarkdownExternalReferenceMap) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderToStaticMarkup( + + + {children} + + , + ); +} + +describe("MarkdownBody external object decoration", () => { + const successReference: MarkdownExternalReferenceMap = { + "https://github.com/acme/web/pull/241": { + providerKey: "github", + objectType: "pull_request", + displayKey: "Github Pull Request", + iconKey: "github", + statusCategory: "succeeded", + statusIconKey: "git-merge", + liveness: "fresh", + statusLabel: "Merged", + displayTitle: "Add external refs", + }, + }; + + it("decorates a known URL with the external status icon and metadata attributes", () => { + const html = render("Take a look: https://github.com/acme/web/pull/241", successReference); + expect(html).toContain('class="paperclip-markdown-external-ref"'); + expect(html).toContain('data-external-link="resolved"'); + expect(html).toContain('data-external-status="succeeded"'); + expect(html).toContain('data-external-liveness="fresh"'); + expect(html).toContain('aria-label="Github Pull Request Merged: Add external refs"'); + expect(html).toContain("github.com/acme/web/pull/241"); + }); + + it("matches a URL even when the user pasted it with different host case or trailing punctuation", () => { + const html = render("see HTTPS://Github.com/acme/web/pull/241#frag.", successReference); + expect(html).toContain('data-external-status="succeeded"'); + }); + + it("renders an unresolved URL as a plain external link with no status affordance", () => { + const html = render("https://random.example.com/path"); + expect(html).not.toContain("paperclip-markdown-external-ref"); + expect(html).toContain('href="https://random.example.com/path"'); + }); + + it("never decorates a URL that lives inside a fenced or inline code block", () => { + const html = render( + "```\nhttps://github.com/acme/web/pull/241\n```\n\nInline: `https://github.com/acme/web/pull/241`", + successReference, + ); + // The fenced/inline literal should still be present as text but the + // decorated anchor should not appear since no `` is created in code. + expect(html).not.toContain('class="paperclip-markdown-external-ref"'); + }); + + it("shows liveness suffix in the aria-label when the object is stale or auth_required", () => { + const html = render("Auth-blocked: https://app.hubspot.com/leads/99", { + "https://app.hubspot.com/leads/99": { + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + statusLabel: "Reconnect", + displayTitle: "Acme deal", + }, + }); + expect(html).toContain('aria-label="HubSpot Reconnect (Requires auth): Acme deal"'); + }); +}); diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index 968e05d8d8..8351a13d23 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -22,7 +22,9 @@ import { queryKeys } from "../lib/queryKeys"; import { cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils"; import { useProjectOrder } from "../hooks/useProjectOrder"; import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships"; +import { useProjectExternalObjectSummary } from "../hooks/useIssueExternalObjects"; import { BudgetSidebarMarker } from "./BudgetSidebarMarker"; +import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary"; import { ProjectTile } from "./ProjectTile"; import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection"; import { Button } from "@/components/ui/button"; @@ -122,6 +124,7 @@ function ProjectItem({ isDragging = false, }: ProjectItemProps) { const routeRef = projectRouteRef(project); + const { summary: externalObjectsSummary } = useProjectExternalObjectSummary(project.id); const link = ( {project.name} + {!rail ? : null} {!rail && project.pauseReason === "budget" ? : null} ); diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index a9130f8cde..52e9f6bfc8 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { TranscriptEntry } from "../../adapters"; -import { MarkdownBody } from "../MarkdownBody"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "../MarkdownBody"; import { cn, formatTokens } from "../../lib/utils"; import { Check, @@ -31,6 +31,7 @@ interface RunTranscriptViewProps { emptyMessage?: string; className?: string; thinkingClassName?: string; + externalReferences?: MarkdownExternalReferenceMap; } type TranscriptBlock = @@ -636,9 +637,11 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole function TranscriptMessageBlock({ block, density, + externalReferences, }: { block: Extract; density: TranscriptDensity; + externalReferences?: MarkdownExternalReferenceMap; }) { const isAssistant = block.role === "assistant"; const compact = density === "compact"; @@ -656,6 +659,7 @@ function TranscriptMessageBlock({ "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", compact ? "text-xs leading-5 text-foreground/85" : "text-sm", )} + externalReferences={externalReferences} > {block.text}
@@ -676,10 +680,12 @@ function TranscriptThinkingBlock({ block, density, className, + externalReferences, }: { block: Extract; density: TranscriptDensity; className?: string; + externalReferences?: MarkdownExternalReferenceMap; }) { return ( {block.text} @@ -1090,9 +1097,11 @@ function TranscriptActivityRow({ function TranscriptEventRow({ block, density, + externalReferences, }: { block: Extract; density: TranscriptDensity; + externalReferences?: MarkdownExternalReferenceMap; }) { const compact = density === "compact"; const toneClasses = @@ -1121,6 +1130,7 @@ function TranscriptEventRow({ "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 text-sky-700 dark:text-sky-300", compact ? "text-[11px] leading-5" : "text-xs leading-5", )} + externalReferences={externalReferences} > {block.text} @@ -1473,6 +1483,7 @@ export function RunTranscriptView({ emptyMessage = "No transcript yet.", className, thinkingClassName, + externalReferences, }: RunTranscriptViewProps) { const blocks = useMemo( () => (mode === "raw" ? [] : normalizeTranscript(entries, streaming)), @@ -1504,9 +1515,20 @@ export function RunTranscriptView({ key={`${block.type}-${block.ts}-${index}`} className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")} > - {block.type === "message" && } + {block.type === "message" && ( + + )} {block.type === "thinking" && ( - + )} {block.type === "tool" && } {block.type === "command_group" && } @@ -1518,7 +1540,13 @@ export function RunTranscriptView({ )} {block.type === "activity" && } - {block.type === "event" && } + {block.type === "event" && ( + + )} ))} diff --git a/ui/src/hooks/useIssueExternalObjects.ts b/ui/src/hooks/useIssueExternalObjects.ts new file mode 100644 index 0000000000..8f8052afb0 --- /dev/null +++ b/ui/src/hooks/useIssueExternalObjects.ts @@ -0,0 +1,250 @@ +import { useCallback, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { + ExternalObjectMention, + ExternalObjectMentionGroup, + ExternalObjectSummary, +} from "@paperclipai/shared"; +import { externalObjectsApi } from "../api/externalObjects"; +import { queryKeys } from "../lib/queryKeys"; +import { normalizeExternalObjectHref } from "../lib/external-object-href"; +import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody"; +import type { ExternalObjectPillData } from "../components/ExternalObjectPill"; +import { instanceSettingsApi } from "../api/instanceSettings"; + +export const EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE = 500; + +export async function fetchIssueExternalObjectSummariesInBatches( + companyId: string, + issueIds: readonly string[], +) { + const summaries: Record = {}; + for (let index = 0; index < issueIds.length; index += EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE) { + const batch = issueIds.slice(index, index + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE); + const response = await externalObjectsApi.getIssueSummaries(companyId, batch); + Object.assign(summaries, response.summaries); + } + return { summaries }; +} + +/** + * Browser-side mention-source label. Keep in sync with the shared formatter + * without coupling this hook to the server-only URL canonicalization helpers. + */ +function formatMentionSourceLabel(mention: ExternalObjectMention): string { + switch (mention.sourceKind) { + case "title": + return "Title"; + case "description": + return "Description"; + case "comment": + return "Comment"; + case "document": + return mention.documentKey ? `Document: ${mention.documentKey}` : "Document"; + case "property": + return mention.propertyKey ? `Property: ${mention.propertyKey}` : "Property"; + case "plugin": + return "Plugin"; + default: + return "Source"; + } +} + +export interface IssueExternalObjectGroup { + pill: ExternalObjectPillData; + mentionCount: number; + sourceLabels: string[]; + group: ExternalObjectMentionGroup; +} + +export interface IssueExternalObjectsResult { + isEnabled: boolean; + groups: IssueExternalObjectGroup[]; + /** Lookup map for `MarkdownBody`'s `externalReferences` prop. */ + markdownReferences: MarkdownExternalReferenceMap; + isLoading: boolean; + isError: boolean; + refetch: () => void; +} + +function useExternalObjectsFeature() { + const query = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + retry: false, + }); + return { + isEnabled: query.data?.enableExternalObjects === true, + isLoaded: query.data !== undefined || query.isError, + }; +} + +/** + * Loads `external_objects` for an issue and produces both the per-group rows + * (used by the property panel and related-work section) and the markdown URL + * lookup map (used by inline decoration). Single source of truth so every + * surface reads from the same query result. + */ +export function useIssueExternalObjects(issueId: string | null | undefined): IssueExternalObjectsResult { + const externalObjectsFeature = useExternalObjectsFeature(); + const enabled = externalObjectsFeature.isEnabled && Boolean(issueId); + const query = useQuery({ + queryKey: queryKeys.externalObjects.byIssue(issueId ?? "__none__"), + queryFn: () => externalObjectsApi.listForIssue(issueId!), + enabled, + staleTime: 60_000, + }); + + const groups = useMemo(() => { + const data = query.data ?? []; + return data + .filter((entry): entry is ExternalObjectMentionGroup => Boolean(entry.object)) + .map((entry) => { + const object = entry.object!; + const sourceLabels = entry.sourceLabels && entry.sourceLabels.length > 0 + ? entry.sourceLabels + : Array.from(new Set(entry.mentions.map(formatMentionSourceLabel))); + return { + group: entry, + mentionCount: entry.mentionCount ?? entry.mentions.length, + sourceLabels, + pill: { + providerKey: object.providerKey, + objectType: object.objectType, + displayKey: object.displayKey, + iconKey: object.iconKey, + statusCategory: object.statusCategory, + liveness: object.liveness, + displayTitle: object.displayTitle, + statusLabel: object.statusLabel, + statusIconKey: object.statusIconKey, + url: object.sanitizedCanonicalUrl, + }, + }; + }); + }, [query.data]); + + const markdownReferences = useMemo(() => { + const result: MarkdownExternalReferenceMap = {}; + for (const { group } of groups) { + const object = group.object; + if (!object) continue; + // Index by the object's canonical URL. + const canonical = normalizeExternalObjectHref(object.sanitizedCanonicalUrl ?? null); + if (canonical) { + result[canonical] = { + providerKey: object.providerKey, + objectType: object.objectType, + displayKey: object.displayKey, + iconKey: object.iconKey, + statusCategory: object.statusCategory, + liveness: object.liveness, + statusLabel: object.statusLabel, + statusIconKey: object.statusIconKey, + displayTitle: object.displayTitle, + }; + } + // Also index by every mention's sanitized display URL so user-pasted + // hrefs that differ only in case/punctuation still resolve. + for (const mention of group.mentions) { + const normalizedMention = normalizeExternalObjectHref( + mention.sanitizedDisplayUrl ?? null, + ); + if (normalizedMention && !result[normalizedMention]) { + result[normalizedMention] = { + providerKey: object.providerKey, + objectType: object.objectType, + displayKey: object.displayKey, + iconKey: object.iconKey, + statusCategory: object.statusCategory, + liveness: object.liveness, + statusLabel: object.statusLabel, + statusIconKey: object.statusIconKey, + displayTitle: object.displayTitle, + }; + } + } + } + return result; + }, [groups]); + + const refetch = useCallback(() => { + void query.refetch(); + }, [query.refetch]); + + return { + isEnabled: externalObjectsFeature.isEnabled, + groups, + markdownReferences, + isLoading: enabled && query.isLoading, + isError: query.isError, + refetch, + }; +} + +export function useIssueExternalObjectSummary(issueId: string | null | undefined): { + summary: ExternalObjectSummary | null; + isLoading: boolean; +} { + const externalObjectsFeature = useExternalObjectsFeature(); + const enabled = externalObjectsFeature.isEnabled && Boolean(issueId); + const query = useQuery({ + queryKey: queryKeys.externalObjects.issueSummary(issueId ?? "__none__"), + queryFn: () => externalObjectsApi.getIssueSummary(issueId!), + enabled, + staleTime: 60_000, + }); + return { + summary: query.data ?? null, + isLoading: enabled && query.isLoading, + }; +} + +export function useIssueExternalObjectSummaries( + companyId: string | null | undefined, + issueIds: readonly string[], +): { + summaries: Map; + isLoading: boolean; + isReady: boolean; +} { + const externalObjectsFeature = useExternalObjectsFeature(); + const normalizedIssueIds = useMemo( + () => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(), + [issueIds], + ); + const enabled = externalObjectsFeature.isEnabled && Boolean(companyId) && normalizedIssueIds.length > 0; + const query = useQuery({ + queryKey: queryKeys.externalObjects.issueSummaries(companyId ?? "__none__", normalizedIssueIds), + queryFn: () => fetchIssueExternalObjectSummariesInBatches(companyId!, normalizedIssueIds), + enabled, + staleTime: 60_000, + }); + const summaries = useMemo( + () => new Map(Object.entries(query.data?.summaries ?? {})), + [query.data?.summaries], + ); + return { + summaries, + isLoading: enabled && query.isLoading, + isReady: externalObjectsFeature.isLoaded && (!enabled || query.isSuccess), + }; +} + +export function useProjectExternalObjectSummary(projectId: string | null | undefined): { + summary: ExternalObjectSummary | null; + isLoading: boolean; +} { + const externalObjectsFeature = useExternalObjectsFeature(); + const enabled = externalObjectsFeature.isEnabled && Boolean(projectId); + const query = useQuery({ + queryKey: queryKeys.externalObjects.projectSummary(projectId ?? "__none__"), + queryFn: () => externalObjectsApi.getProjectSummary(projectId!), + enabled, + staleTime: 60_000, + }); + return { + summary: query.data ?? null, + isLoading: enabled && query.isLoading, + }; +} diff --git a/ui/src/hooks/usePrefersReducedMotion.ts b/ui/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 0000000000..88bdd6a611 --- /dev/null +++ b/ui/src/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; + +function getInitialValue(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia(QUERY).matches; +} + +export function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(getInitialValue); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const media = window.matchMedia(QUERY); + const handler = (event: MediaQueryListEvent) => setReduced(event.matches); + setReduced(media.matches); + media.addEventListener("change", handler); + return () => media.removeEventListener("change", handler); + }, []); + + return reduced; +} diff --git a/ui/src/index.css b/ui/src/index.css index 6c5e2b7c58..f3da45a2ce 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1097,6 +1097,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { white-space: nowrap; } +/* External object URL decorations follow the same pattern: the host renders + the icon prefix, the link text is left untouched (Postel's Law). */ +.paperclip-markdown-external-ref { + display: inline; + white-space: nowrap; +} + .dark .paperclip-markdown a { color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); } @@ -1206,6 +1213,25 @@ span.paperclip-project-mention-chip { white-space: nowrap; } +/* Touch target floor: WCAG 2.5.5 expects ≥24px on coarse pointers. The base + chip is 22px; bump it on touch viewports without disturbing inline metrics. */ +@media (pointer: coarse) { + a.paperclip-mention-chip, + a.paperclip-project-mention-chip, + span.paperclip-mention-chip, + span.paperclip-project-mention-chip { + min-height: 24px; + } +} + +/* External object pills: variant marker the host renders for known-resolved + external URLs. Inline status icon is rendered by ExternalObjectStatusIcon. */ +a.paperclip-mention-chip[data-mention-kind="external-object"], +span.paperclip-mention-chip[data-mention-kind="external-object"] { + /* Inherit base chip styling; status colour comes from inline classes so the + host can vary tone per category without defining a CSS variant per state. */ +} + /* When the identifier inside a chip is backtick-wrapped in markdown, strip the inline-code monospace/gray styling so the pill label reads cleanly. */ .paperclip-markdown a.paperclip-mention-chip code, diff --git a/ui/src/lib/external-object-href.ts b/ui/src/lib/external-object-href.ts new file mode 100644 index 0000000000..eeb2d3b660 --- /dev/null +++ b/ui/src/lib/external-object-href.ts @@ -0,0 +1,32 @@ +/** + * Browser-safe URL key for matching markdown hrefs against the + * `external_objects.sanitizedCanonicalUrl` returned by the host API. + * + * The shared/server canonicalizer is the source of truth for the canonical + * URL string, but it imports from `node:crypto` (it also produces an identity + * hash) and therefore cannot run in the browser. We replicate just the URL + * normalization here: + * + * - protocol must be http or https + * - reject userinfo (`username:password@`) + * - lowercase host + * - drop query string + fragment + * - default empty pathname to `/` + */ +export function normalizeExternalObjectHref(value: string | null | undefined): string | null { + if (!value) return null; + + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + + const scheme = url.protocol === "https:" ? "https" : "http"; + const path = url.pathname || "/"; + return `${scheme}://${url.host.toLowerCase()}${path}`; +} diff --git a/ui/src/lib/external-objects.test.ts b/ui/src/lib/external-objects.test.ts new file mode 100644 index 0000000000..70ae3560c4 --- /dev/null +++ b/ui/src/lib/external-objects.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExternalObjectSummary } from "@paperclipai/shared"; +import { externalObjectsApi } from "../api/externalObjects"; +import { + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE, + fetchIssueExternalObjectSummariesInBatches, +} from "../hooks/useIssueExternalObjects"; +import { + dominantExternalObjectTone, + externalObjectCategoryLabel, + externalObjectDominantCount, + externalObjectFallbackTone, + externalObjectIconForCategory, + externalObjectIconForKey, + externalObjectLivenessLabel, + externalObjectProviderLabel, + externalObjectToneSeverity, + externalObjectTypeLabel, + sortExternalObjectsBySeverity, +} from "./external-objects"; +import { normalizeExternalObjectHref } from "./external-object-href"; + +vi.mock("../api/externalObjects", () => ({ + externalObjectsApi: { + getIssueSummaries: vi.fn(), + }, +})); + +const emptySummary: ExternalObjectSummary = { + total: 0, + byStatusCategory: {}, + byLiveness: {}, + highestSeverity: "neutral", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], +}; + +describe("normalizeExternalObjectHref", () => { + it("lowercases the host (case preserving the path) and drops query/fragment", () => { + expect( + normalizeExternalObjectHref("HTTPS://Github.com/Acme/Web/pull/241?token=abc#frag"), + ).toBe("https://github.com/Acme/Web/pull/241"); + }); + + it("rejects non-http(s) and userinfo-bearing URLs", () => { + expect(normalizeExternalObjectHref("javascript:alert(1)")).toBeNull(); + expect(normalizeExternalObjectHref("ftp://example.com/file")).toBeNull(); + expect(normalizeExternalObjectHref("https://user:pass@example.com/")).toBeNull(); + expect(normalizeExternalObjectHref(null)).toBeNull(); + expect(normalizeExternalObjectHref(undefined)).toBeNull(); + expect(normalizeExternalObjectHref("not a url")).toBeNull(); + }); + + it("defaults pathless URLs to /", () => { + expect(normalizeExternalObjectHref("https://example.com")).toBe("https://example.com/"); + }); +}); + +describe("fetchIssueExternalObjectSummariesInBatches", () => { + afterEach(() => { + vi.mocked(externalObjectsApi.getIssueSummaries).mockReset(); + }); + + it("chunks bulk summary requests below the server validation cap and merges results", async () => { + const issueIds = Array.from( + { length: EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE * 2 + 3 }, + (_entry, index) => `issue-${index}`, + ); + vi.mocked(externalObjectsApi.getIssueSummaries).mockImplementation(async (_companyId, ids) => ({ + summaries: Object.fromEntries(ids.map((id) => [id, { ...emptySummary, total: id.endsWith("-0") ? 1 : 0 }])), + })); + + const result = await fetchIssueExternalObjectSummariesInBatches("company-1", issueIds); + + expect(externalObjectsApi.getIssueSummaries).toHaveBeenCalledTimes(3); + expect(vi.mocked(externalObjectsApi.getIssueSummaries).mock.calls.map((call) => call[1].length)).toEqual([ + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE, + EXTERNAL_OBJECT_SUMMARY_BATCH_SIZE, + 3, + ]); + expect(Object.keys(result.summaries)).toHaveLength(issueIds.length); + expect(result.summaries["issue-0"]?.total).toBe(1); + }); +}); + +describe("external-objects helpers", () => { + it("labels categories with copy from the UX spec", () => { + expect(externalObjectCategoryLabel("auth_required")).toBe("Authorization required"); + expect(externalObjectCategoryLabel("succeeded")).toBe("Succeeded"); + expect(externalObjectCategoryLabel("unknown")).toBe("Not yet resolved"); + }); + + it("labels liveness states with non-tech copy", () => { + expect(externalObjectLivenessLabel("stale")).toBe("Stale"); + expect(externalObjectLivenessLabel("auth_required")).toBe("Requires auth"); + expect(externalObjectLivenessLabel("fresh")).toBe("Fresh"); + }); + + it("falls back to a humanised label for unknown providers and types", () => { + expect(externalObjectProviderLabel("github")).toBe("GitHub"); + expect(externalObjectProviderLabel("hubspot_marketing")).toBe("Hubspot Marketing"); + expect(externalObjectProviderLabel(null)).toBe("External"); + expect(externalObjectTypeLabel("workflow_run")).toBe("workflow run"); + expect(externalObjectTypeLabel(null)).toBe("object"); + }); + + it("orders tones from danger down to muted", () => { + expect(externalObjectToneSeverity("danger")).toBeGreaterThan(externalObjectToneSeverity("warning")); + expect(externalObjectToneSeverity("warning")).toBeGreaterThan(externalObjectToneSeverity("info")); + expect(externalObjectToneSeverity("info")).toBeGreaterThan(externalObjectToneSeverity("success")); + expect(externalObjectToneSeverity("success")).toBeGreaterThan(externalObjectToneSeverity("muted")); + expect(externalObjectToneSeverity(null)).toBe(0); + expect(externalObjectToneSeverity("nonsense")).toBe(0); + }); + + it("maps every spec category to a fallback tone", () => { + expect(externalObjectFallbackTone("failed")).toBe("danger"); + expect(externalObjectFallbackTone("waiting")).toBe("warning"); + expect(externalObjectFallbackTone("running")).toBe("info"); + expect(externalObjectFallbackTone("succeeded")).toBe("success"); + expect(externalObjectFallbackTone("auth_required")).toBe("warning"); + expect(externalObjectFallbackTone("unreachable")).toBe("danger"); + }); + + it("returns the spec lucide icon names for every category", () => { + expect(externalObjectIconForCategory("succeeded").displayName ?? "").toMatch(/CheckCircle2|Check/); + expect(externalObjectIconForCategory("failed").displayName ?? "").toMatch(/XCircle|X/); + expect(externalObjectIconForCategory("auth_required").displayName ?? "").toMatch(/KeyRound|Key/); + expect(externalObjectIconForCategory("unreachable").displayName ?? "").toMatch(/CloudOff|Cloud/); + expect(externalObjectIconForCategory("running").displayName ?? "").toMatch(/Loader2|Loader/); + }); + + it("maps provider-controlled icon keys through host-owned icons", () => { + expect(externalObjectIconForKey("github")?.displayName ?? "").toMatch(/Github/i); + expect(externalObjectIconForKey("git-pull-request")?.displayName ?? "").toMatch(/GitPullRequest/i); + expect(externalObjectIconForKey("unknown-provider-icon")).toBeNull(); + }); + + it("sorts items by severity first, preserving insertion order within a tone", () => { + const items = [ + { id: "a", statusTone: "info", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "running", liveness: "fresh", isTerminal: false }, + { id: "b", statusTone: "danger", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", liveness: "fresh", isTerminal: false }, + { id: "c", statusTone: "warning", providerKey: "hubspot", objectType: "lead", displayTitle: null, statusCategory: "waiting", liveness: "fresh", isTerminal: false }, + { id: "d", statusTone: "danger", providerKey: "github", objectType: "issue", displayTitle: null, statusCategory: "blocked", liveness: "fresh", isTerminal: false }, + ] as const; + const sorted = sortExternalObjectsBySeverity(items as never); + expect(sorted.map((item) => item.id)).toEqual(["b", "d", "c", "a"]); + }); + + it("hides the rollup when every item is in a muted tone", () => { + const summary = { + total: 2, + byStatusCategory: { closed: 2 }, + byLiveness: { fresh: 2 }, + highestSeverity: "muted" as const, + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [ + { id: "a", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true }, + { id: "b", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true }, + ], + }; + expect(dominantExternalObjectTone(summary)).toBeNull(); + expect(externalObjectDominantCount(summary)).toBe(0); + }); + + it("counts only the dominant-severity items in the rollup", () => { + const summary = { + total: 5, + byStatusCategory: { failed: 3, succeeded: 2 }, + byLiveness: { fresh: 5 }, + highestSeverity: "danger" as const, + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [ + { id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false }, + { id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false }, + { id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false }, + { id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true }, + { id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true }, + ], + }; + expect(dominantExternalObjectTone(summary)).toBe("danger"); + expect(externalObjectDominantCount(summary)).toBe(3); + }); +}); diff --git a/ui/src/lib/external-objects.ts b/ui/src/lib/external-objects.ts new file mode 100644 index 0000000000..76ca0c91c0 --- /dev/null +++ b/ui/src/lib/external-objects.ts @@ -0,0 +1,236 @@ +import { + AlertCircle, + AlertOctagon, + Archive, + CheckCircle2, + Circle, + CircleDashed, + CircleDot, + Clock, + CloudOff, + GitMerge, + Github, + GitPullRequest, + KeyRound, + Loader2, + XCircle, + type LucideIcon, +} from "lucide-react"; +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, + ExternalObjectStatusTone, + ExternalObjectSummary, + ExternalObjectSummaryItem, +} from "@paperclipai/shared"; + +/** + * Lucide icon for each status category. The mapping is host-owned per the + * Phase 1B security review — providers never inject inline React. + */ +export const externalObjectCategoryIcon: Record = { + unknown: CircleDashed, + open: CircleDot, + waiting: Clock, + running: Loader2, + succeeded: CheckCircle2, + failed: XCircle, + blocked: AlertOctagon, + closed: Circle, + archived: Archive, + auth_required: KeyRound, + unreachable: CloudOff, +}; + +export const externalObjectCategoryIconDefault: LucideIcon = CircleDashed; + +export function externalObjectIconForCategory(category: string): LucideIcon { + return externalObjectCategoryIcon[category] ?? externalObjectCategoryIconDefault; +} + +const EXTERNAL_OBJECT_ICON_KEYS: Record = { + archive: Archive, + check: CheckCircle2, + "check-circle": CheckCircle2, + circle: Circle, + "circle-dot": CircleDot, + clock: Clock, + github: Github, + "git-merge": GitMerge, + "git-pull-request": GitPullRequest, + key: KeyRound, + loader: Loader2, + "x-circle": XCircle, +}; + +export function externalObjectIconForKey(iconKey: string | null | undefined): LucideIcon | null { + if (!iconKey) return null; + return EXTERNAL_OBJECT_ICON_KEYS[iconKey] ?? null; +} + +export function externalObjectIconForLiveness(liveness: string): LucideIcon | null { + if (liveness === "auth_required") return KeyRound; + if (liveness === "unreachable") return CloudOff; + return null; +} + +const CATEGORY_LABELS: Record = { + unknown: "Not yet resolved", + open: "Open", + waiting: "Waiting", + running: "Running", + succeeded: "Succeeded", + failed: "Failed", + blocked: "Blocked", + closed: "Closed", + archived: "Archived", + auth_required: "Authorization required", + unreachable: "Unreachable", +}; + +export function externalObjectCategoryLabel(category: string): string { + return CATEGORY_LABELS[category] ?? category.replace(/_/g, " "); +} + +const LIVENESS_LABELS: Record = { + unknown: "Not yet refreshed", + fresh: "Fresh", + stale: "Stale", + auth_required: "Requires auth", + unreachable: "Unreachable", +}; + +export function externalObjectLivenessLabel(liveness: string): string { + return LIVENESS_LABELS[liveness] ?? liveness.replace(/_/g, " "); +} + +/** + * Higher number = more attention-worthy. The rollups in §5 sort by tone first. + * Mirrors `externalObjectStatusToneSeverity` in `status-colors.ts`. + */ +const TONE_SEVERITY: Record = { + muted: 0, + neutral: 1, + success: 2, + info: 3, + warning: 4, + danger: 5, +}; + +export function externalObjectToneSeverity(tone: string | null | undefined): number { + if (!tone) return 0; + return TONE_SEVERITY[tone] ?? 0; +} + +const CATEGORY_TONE_FALLBACK: Record = { + unknown: "muted", + open: "info", + waiting: "warning", + running: "info", + succeeded: "success", + failed: "danger", + blocked: "danger", + closed: "muted", + archived: "muted", + auth_required: "warning", + unreachable: "danger", +}; + +export function externalObjectFallbackTone( + category: ExternalObjectStatusCategory, +): ExternalObjectStatusTone { + return CATEGORY_TONE_FALLBACK[category] ?? "neutral"; +} + +const PROVIDER_LABELS: Record = { + github: "GitHub", + github_pull_request: "GitHub", + github_issue: "GitHub", + hubspot: "HubSpot", + linear: "Linear", + jira: "Jira", + notion: "Notion", + asana: "Asana", +}; + +export function externalObjectProviderLabel(providerKey: string | null | undefined): string { + if (!providerKey) return "External"; + const lookup = PROVIDER_LABELS[providerKey]; + if (lookup) return lookup; + return providerKey + .split(/[._-]/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +const OBJECT_TYPE_LABELS: Record = { + pull_request: "pull request", + issue: "issue", + deployment: "deployment", + workflow_run: "workflow run", + ticket: "ticket", + lead: "lead", +}; + +export function externalObjectTypeLabel(objectType: string | null | undefined): string { + if (!objectType) return "object"; + return OBJECT_TYPE_LABELS[objectType] ?? objectType.replace(/_/g, " "); +} + +/** + * Sort summary items by severity-first ordering: danger → warning → info → + * success → muted/neutral. Within a tone, items keep their incoming order so + * server-side ordering (e.g. most recent change first) is preserved. + */ +export function sortExternalObjectsBySeverity( + items: readonly T[], +): T[] { + return [...items] + .map((item, index) => ({ item, index })) + .sort((a, b) => { + const aTone = externalObjectToneSeverity(a.item.statusTone); + const bTone = externalObjectToneSeverity(b.item.statusTone); + if (aTone !== bTone) return bTone - aTone; + return a.index - b.index; + }) + .map(({ item }) => item); +} + +/** + * Compute the dominant tone in a summary — used by sidebar / list rollups. + * Falls back to `null` when no objects are present or every tone is `muted`. + */ +export function dominantExternalObjectTone( + summary: Pick | null | undefined, +): ExternalObjectStatusTone | null { + if (!summary) return null; + const tone = summary.highestSeverity; + if (!tone) return null; + if (externalObjectToneSeverity(tone) <= TONE_SEVERITY.muted) return null; + return tone; +} + +/** + * For the sidebar / list rollup we want the count of objects matching the + * dominant severity (e.g. "3 failed PRs"), not the global total. Returns 0 + * whenever the dominant tone is muted so callers can render based on the + * count without double-checking the rollup-hide rule. + */ +export function externalObjectDominantCount( + summary: Pick | null | undefined, +): number { + if (!summary) return 0; + const tone = dominantExternalObjectTone(summary); + if (!tone) return 0; + return summary.objects.filter((object) => object.statusTone === tone).length; +} + +/** + * Reduced motion support — match `prefers-reduced-motion: reduce` so the + * spinning Loader2 stays static when requested. Hooks consume this via React + * to react to runtime changes; non-hook callers can use the helper directly. + */ +export function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index d18bc7c89e..e17c8418f6 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -5,6 +5,7 @@ import type { Approval, DashboardSummary, ExecutionWorkspace, + ExternalObjectSummary, HeartbeatRun, Issue, JoinRequest, @@ -909,6 +910,7 @@ describe("inbox helpers", () => { projects: [], workspaces: [], liveOnly: false, + externalObjectStatuses: [], hideRoutineExecutions: true, }, }).map((issue) => issue.id), @@ -929,6 +931,7 @@ describe("inbox helpers", () => { projects: [], workspaces: [], liveOnly: false, + externalObjectStatuses: [], hideRoutineExecutions: true, }, }), @@ -949,12 +952,65 @@ describe("inbox helpers", () => { projects: [], workspaces: [], liveOnly: false, + externalObjectStatuses: [], hideRoutineExecutions: true, }, }), ).toEqual([]); }); + it("applies external-object filters to remote inbox search supplements", () => { + const failedMatch = makeIssue("failed-match", false); + const freshMatch = makeIssue("fresh-match", false); + const summaries = new Map([ + ["failed-match", { + total: 1, + byStatusCategory: { failed: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "danger", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }], + ["fresh-match", { + total: 1, + byStatusCategory: { succeeded: 1 }, + byLiveness: { fresh: 1 }, + highestSeverity: "success", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + }], + ]); + + expect( + getInboxSearchSupplementIssues({ + query: "github", + filteredWorkItems: [], + archivedSearchIssues: [], + remoteIssues: [failedMatch, freshMatch], + issueFilters: { + statuses: [], + priorities: [], + assignees: [], + creators: [], + labels: [], + projects: [], + workspaces: [], + liveOnly: false, + externalObjectStatuses: ["failed"], + hideRoutineExecutions: true, + }, + issueFilterContext: { + externalObjectSummaryByIssueId: summaries, + externalObjectSummariesReady: true, + }, + }).map((issue) => issue.id), + ).toEqual(["failed-match"]); + }); + it("keeps inbox search matches ahead of archived and other result sections", () => { const inboxIssue = makeIssue("inbox", false); inboxIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z"); @@ -1021,6 +1077,7 @@ describe("inbox helpers", () => { projects: ["project-1"], workspaces: ["workspace-1"], liveOnly: true, + externalObjectStatuses: [], hideRoutineExecutions: false, }, }); @@ -1036,6 +1093,7 @@ describe("inbox helpers", () => { projects: [], workspaces: [], liveOnly: false, + externalObjectStatuses: [], hideRoutineExecutions: true, }, }); @@ -1052,6 +1110,7 @@ describe("inbox helpers", () => { projects: ["project-1"], workspaces: ["workspace-1"], liveOnly: true, + externalObjectStatuses: [], hideRoutineExecutions: false, }, }); @@ -1067,6 +1126,7 @@ describe("inbox helpers", () => { projects: [], workspaces: [], liveOnly: false, + externalObjectStatuses: [], hideRoutineExecutions: true, }, }); @@ -1101,6 +1161,7 @@ describe("inbox helpers", () => { projects: ["project-1"], workspaces: ["workspace-1"], liveOnly: false, + externalObjectStatuses: [], hideRoutineExecutions: false, }, }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 780aeb3bcf..48885d8085 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -11,6 +11,7 @@ import { defaultIssueFilterState, normalizeIssueFilterState, type IssueFilterState, + type IssueFilterWorkspaceContext, } from "./issue-filters"; import { formatAssigneeUserLabel } from "./assignees"; @@ -462,6 +463,7 @@ export function getInboxSearchSupplementIssues({ currentUserId, enableRoutineVisibilityFilter = false, liveIssueIds, + issueFilterContext = {}, }: { query: string; filteredWorkItems: InboxWorkItem[]; @@ -471,6 +473,7 @@ export function getInboxSearchSupplementIssues({ currentUserId?: string | null; enableRoutineVisibilityFilter?: boolean; liveIssueIds?: ReadonlySet; + issueFilterContext?: IssueFilterWorkspaceContext; }): Issue[] { const normalizedQuery = query.trim(); if (!normalizedQuery) return []; @@ -480,7 +483,14 @@ export function getInboxSearchSupplementIssues({ .map((item) => item.issue.id), ...archivedSearchIssues.map((issue) => issue.id), ]); - return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds) + return applyIssueFilters( + remoteIssues, + issueFilters, + currentUserId, + enableRoutineVisibilityFilter, + liveIssueIds, + issueFilterContext, + ) .filter((issue) => !visibleIssueIds.has(issue.id)); } diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts index bb0afde7ac..4e210b19bf 100644 --- a/ui/src/lib/issue-filters.test.ts +++ b/ui/src/lib/issue-filters.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import type { Issue } from "@paperclipai/shared"; +import type { ExternalObjectSummary, Issue } from "@paperclipai/shared"; import { applyIssueFilters, countActiveIssueFilters, @@ -51,6 +51,20 @@ function makeIssue(overrides: Partial = {}): Issue { }; } +function makeExternalObjectSummary(overrides: Partial = {}): ExternalObjectSummary { + return { + total: 1, + byStatusCategory: {}, + byLiveness: { fresh: 1 }, + highestSeverity: "neutral", + staleCount: 0, + authRequiredCount: 0, + unreachableCount: 0, + objects: [], + ...overrides, + }; +} + describe("issue filters", () => { it("filters issues by creator across agents and users", () => { const issues = [ @@ -169,4 +183,62 @@ describe("issue filters", () => { new Set(["workspace-default"]), )).toBe(true); }); + + it.each([ + ["failed", ["failed-issue", "blocked-issue"]], + ["auth_required", ["auth-issue"]], + ["stale", ["stale-issue"]], + ["none", ["none-issue"]], + ])("filters issues by external-object status token %s", (externalObjectStatus, expectedIssueIds) => { + const issues = [ + makeIssue({ id: "failed-issue" }), + makeIssue({ id: "blocked-issue" }), + makeIssue({ id: "auth-issue" }), + makeIssue({ id: "stale-issue" }), + makeIssue({ id: "none-issue" }), + makeIssue({ id: "fresh-issue" }), + ]; + const summaries = new Map([ + ["failed-issue", makeExternalObjectSummary({ byStatusCategory: { failed: 1 }, highestSeverity: "danger" })], + ["blocked-issue", makeExternalObjectSummary({ byStatusCategory: { blocked: 1 }, highestSeverity: "danger" })], + ["auth-issue", makeExternalObjectSummary({ + byLiveness: { auth_required: 1 }, + highestSeverity: "danger", + authRequiredCount: 1, + })], + ["stale-issue", makeExternalObjectSummary({ + byLiveness: { stale: 1 }, + highestSeverity: "warning", + staleCount: 1, + })], + ["fresh-issue", makeExternalObjectSummary({ byStatusCategory: { succeeded: 1 }, highestSeverity: "success" })], + ]); + + const filtered = applyIssueFilters( + issues, + { ...defaultIssueFilterState, externalObjectStatuses: [externalObjectStatus] }, + null, + false, + undefined, + { + externalObjectSummaryByIssueId: summaries, + externalObjectSummariesReady: true, + }, + ); + + expect(filtered.map((issue) => issue.id)).toEqual(expectedIssueIds); + }); + + it("does not apply external-object filters before summaries are ready", () => { + const filtered = applyIssueFilters( + [makeIssue({ id: "issue-1" })], + { ...defaultIssueFilterState, externalObjectStatuses: ["none"] }, + null, + false, + undefined, + { externalObjectSummaryByIssueId: new Map(), externalObjectSummariesReady: false }, + ); + + expect(filtered).toEqual([]); + }); }); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index 63ce019378..a0576a5305 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -1,4 +1,4 @@ -import type { Issue } from "@paperclipai/shared"; +import type { ExternalObjectSummary, Issue } from "@paperclipai/shared"; export type IssueFilterWorkspaceLookup = { mode?: string | null; @@ -8,6 +8,8 @@ export type IssueFilterWorkspaceLookup = { export type IssueFilterWorkspaceContext = { executionWorkspaceById?: ReadonlyMap; defaultProjectWorkspaceIdByProjectId?: ReadonlyMap; + externalObjectSummaryByIssueId?: ReadonlyMap; + externalObjectSummariesReady?: boolean; }; export type IssueFilterState = { @@ -19,6 +21,21 @@ export type IssueFilterState = { projects: string[]; workspaces: string[]; liveOnly?: boolean; + /** + * External object status filter. Values are special tokens that map to + * properties of the issue's external-object summary (rather than to a + * single category) so the filter UI can describe intent rather than every + * possible permutation. + * + * - `failed` — any external object with `statusCategory in (failed, blocked)` + * - `waiting` — any external object with `statusCategory in (waiting)` + * - `running` — any external object with `statusCategory in (running)` + * - `auth_required` — any external object with `liveness == auth_required` + * - `unreachable` — any external object with `liveness == unreachable` + * - `stale` — any external object with `liveness == stale` + * - `none` — issues with zero external objects + */ + externalObjectStatuses: string[]; hideRoutineExecutions: boolean; }; @@ -31,9 +48,34 @@ export const defaultIssueFilterState: IssueFilterState = { projects: [], workspaces: [], liveOnly: false, + externalObjectStatuses: [], hideRoutineExecutions: false, }; +export const externalObjectFilterOrder = [ + "failed", + "waiting", + "running", + "auth_required", + "unreachable", + "stale", + "none", +]; + +const EXTERNAL_OBJECT_FILTER_LABELS: Record = { + failed: "Any failed", + waiting: "Any waiting", + running: "Any running", + auth_required: "Auth required", + unreachable: "Unreachable", + stale: "Stale", + none: "No external objects", +}; + +export function externalObjectFilterLabel(value: string): string { + return EXTERNAL_OBJECT_FILTER_LABELS[value] ?? issueFilterLabel(value); +} + export const issueStatusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; export const issuePriorityOrder = ["critical", "high", "medium", "low"]; @@ -72,6 +114,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState { projects: normalizeIssueFilterValueArray(candidate.projects), workspaces: normalizeIssueFilterValueArray(candidate.workspaces), liveOnly: candidate.liveOnly === true, + externalObjectStatuses: normalizeIssueFilterValueArray(candidate.externalObjectStatuses), hideRoutineExecutions: candidate.hideRoutineExecutions === true, }; } @@ -118,6 +161,39 @@ export function shouldIncludeIssueFilterWorkspaceOption( && defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId)); } +function summaryRecordCount(record: Record | undefined, key: string): number { + return record?.[key] ?? 0; +} + +function issueMatchesExternalObjectStatusFilter( + summary: ExternalObjectSummary | null | undefined, + value: string, +): boolean { + const total = summary?.total ?? 0; + switch (value) { + case "failed": + return summaryRecordCount(summary?.byStatusCategory, "failed") > 0 + || summaryRecordCount(summary?.byStatusCategory, "blocked") > 0; + case "waiting": + return summaryRecordCount(summary?.byStatusCategory, "waiting") > 0; + case "running": + return summaryRecordCount(summary?.byStatusCategory, "running") > 0; + case "auth_required": + return (summary?.authRequiredCount ?? 0) > 0 + || summaryRecordCount(summary?.byLiveness, "auth_required") > 0; + case "unreachable": + return (summary?.unreachableCount ?? 0) > 0 + || summaryRecordCount(summary?.byLiveness, "unreachable") > 0; + case "stale": + return (summary?.staleCount ?? 0) > 0 + || summaryRecordCount(summary?.byLiveness, "stale") > 0; + case "none": + return total === 0; + default: + return false; + } +} + export function applyIssueFilters( issues: Issue[], state: IssueFilterState, @@ -166,6 +242,16 @@ export function applyIssueFilters( return workspaceId != null && state.workspaces.includes(workspaceId); }); } + if (state.externalObjectStatuses.length > 0) { + const summaries = workspaceContext.externalObjectSummaryByIssueId; + if (!summaries || workspaceContext.externalObjectSummariesReady !== true) return []; + result = result.filter((issue) => { + const summary = summaries.get(issue.id) ?? null; + return state.externalObjectStatuses.some((status) => + issueMatchesExternalObjectStatusFilter(summary, status), + ); + }); + } return result; } @@ -182,6 +268,7 @@ export function countActiveIssueFilters( if (state.projects.length > 0) count += 1; if (state.workspaces.length > 0) count += 1; if (state.liveOnly) count += 1; + if (state.externalObjectStatuses.length > 0) count += 1; if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1; return count; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index d5429df38a..8583974684 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -139,6 +139,13 @@ export const queryKeys = { list: (companyId: string) => ["projects", companyId] as const, detail: (id: string) => ["projects", "detail", id] as const, }, + externalObjects: { + byIssue: (issueId: string) => ["external-objects", "by-issue", issueId] as const, + issueSummary: (issueId: string) => ["external-objects", "issue-summary", issueId] as const, + issueSummaries: (companyId: string, issueIds: readonly string[]) => + ["external-objects", "issue-summaries", companyId, issueIds] as const, + projectSummary: (projectId: string) => ["external-objects", "project-summary", projectId] as const, + }, goals: { list: (companyId: string) => ["goals", companyId] as const, detail: (id: string) => ["goals", "detail", id] as const, diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts index bcedcedca7..267ade6830 100644 --- a/ui/src/lib/status-colors.ts +++ b/ui/src/lib/status-colors.ts @@ -217,3 +217,86 @@ export const priorityColor: Record = { }; export const priorityColorDefault = "text-yellow-600 dark:text-yellow-400"; + +// --------------------------------------------------------------------------- +// External object status — colors & severity ranking +// --------------------------------------------------------------------------- +// +// Categories come from `EXTERNAL_OBJECT_STATUS_CATEGORIES` in @paperclipai/shared. +// The map keys here intentionally mirror the union — keep them in sync. +// +// Tone reuse rationale (see UX spec §1): +// unknown → backlog hue (muted, dashed circle) +// open → todo / blue +// waiting → amber (distinct from internal in_progress yellow) +// running → cyan, animated when motion is allowed +// succeeded → done / green +// failed → red +// blocked → red +// closed → muted neutral +// archived → muted neutral +// auth_required → amber + dashed +// unreachable → red + dashed + +export const externalObjectStatusIcon: Record = { + unknown: "text-muted-foreground border-muted-foreground", + open: "text-blue-600 border-blue-600 dark:text-blue-400 dark:border-blue-400", + waiting: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400", + running: "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400", + succeeded: "text-green-600 border-green-600 dark:text-green-400 dark:border-green-400", + failed: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400", + blocked: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400", + closed: "text-neutral-500 border-neutral-500", + archived: "text-neutral-500 border-neutral-500", + auth_required: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400", + unreachable: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400", +}; + +export const externalObjectStatusIconDefault = "text-muted-foreground border-muted-foreground"; + +export const externalObjectStatusBadge: Record = { + unknown: "bg-muted text-muted-foreground", + open: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300", + waiting: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", + running: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300", + succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", + failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", + blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", + closed: "bg-muted text-muted-foreground", + archived: "bg-muted text-muted-foreground", + auth_required: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", + unreachable: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", +}; + +export const externalObjectStatusBadgeDefault = "bg-muted text-muted-foreground"; + +/** + * Liveness overlay applied on top of the base status tone. We deliberately + * encode it as utility classes (not a tone change) so callers can append the + * overlay to any pill, icon, or marker without redefining colors. + * + * The dashed border + reduced opacity guarantees a non-color differentiator + * for stale / auth_required / unreachable per WCAG 1.4.1. + */ +export const externalObjectLivenessOverlay: Record = { + unknown: "", + fresh: "", + stale: "opacity-70 [border-style:dashed]", + auth_required: "[border-style:dashed]", + unreachable: "[border-style:dashed]", +}; + +/** + * Severity ranking used by sidebar/list rollups. Higher number = more + * attention-worthy. Anything ≤ `muted` should be hidden when summarising. + */ +export const externalObjectStatusToneSeverity: Record = { + muted: 0, + neutral: 1, + success: 2, + info: 3, + warning: 4, + danger: 5, +}; + +export const externalObjectStatusToneSeverityDefault = 0; diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index b63218dc60..5a29c986d4 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -25,6 +25,7 @@ import { useGeneralSettings } from "../context/GeneralSettingsContext"; import { useSidebar } from "../context/SidebarContext"; import { queryKeys } from "../lib/queryKeys"; import { useDialogActions } from "../context/DialogContext"; +import { useIssueExternalObjectSummaries } from "../hooks/useIssueExternalObjects"; import { applyIssueFilters, countActiveIssueFilters, @@ -732,6 +733,7 @@ export function Inbox() { enabled: !!selectedCompanyId, }); const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; + const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) @@ -863,13 +865,49 @@ export function Inbox() { const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]); + const shouldUseIssueSearchSupplement = + !!selectedCompanyId + && normalizedSearchQuery.length > 0; + const { data: remoteIssueSearchResults = [] } = useQuery({ + queryKey: [ + ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), + "inbox-supplement", + ], + queryFn: () => + issuesApi.list(selectedCompanyId!, { + q: normalizedSearchQuery, + limit: 25, + includeRoutineExecutions: true, + }), + enabled: shouldUseIssueSearchSupplement, + placeholderData: (previousData) => previousData, + }); + const inboxIssueIdsForExternalObjectSummaries = useMemo(() => { + const issueIds = new Set(); + for (const issue of mineIssues) issueIds.add(issue.id); + for (const issue of touchedIssues) issueIds.add(issue.id); + for (const issue of remoteIssueSearchResults) issueIds.add(issue.id); + return [...issueIds]; + }, [mineIssues, remoteIssueSearchResults, touchedIssues]); + const { + summaries: externalObjectSummaryByIssueId, + isLoading: externalObjectSummariesLoading, + isReady: externalObjectSummariesReady, + } = useIssueExternalObjectSummaries( + selectedCompanyId, + inboxIssueIdsForExternalObjectSummaries, + ); + const issueFilterContext = useMemo(() => ({ + externalObjectSummaryByIssueId, + externalObjectSummariesReady: externalObjectSummariesReady && !externalObjectSummariesLoading, + }), [externalObjectSummariesLoading, externalObjectSummariesReady, externalObjectSummaryByIssueId]); const visibleMineIssues = useMemo( - () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds), - [mineIssues, issueFilters, currentUserId, liveIssueIds], + () => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + [mineIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const visibleTouchedIssues = useMemo( - () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds), - [touchedIssues, issueFilters, currentUserId, liveIssueIds], + () => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds, issueFilterContext), + [touchedIssues, issueFilters, currentUserId, liveIssueIds, issueFilterContext], ); const unreadTouchedIssues = useMemo( () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), @@ -1160,23 +1198,6 @@ export function Inbox() { visibleTouchedIssues, ], ); - const shouldUseIssueSearchSupplement = - !!selectedCompanyId - && normalizedSearchQuery.length > 0; - const { data: remoteIssueSearchResults = [] } = useQuery({ - queryKey: [ - ...queryKeys.issues.search(selectedCompanyId!, normalizedSearchQuery, undefined, 25), - "inbox-supplement", - ], - queryFn: () => - issuesApi.list(selectedCompanyId!, { - q: normalizedSearchQuery, - limit: 25, - includeRoutineExecutions: true, - }), - enabled: shouldUseIssueSearchSupplement, - placeholderData: (previousData) => previousData, - }); const issueSearchSupplementResults = useMemo( () => getInboxSearchSupplementIssues({ @@ -1188,11 +1209,13 @@ export function Inbox() { currentUserId, enableRoutineVisibilityFilter: true, liveIssueIds, + issueFilterContext, }), [ archivedSearchIssues, currentUserId, filteredWorkItems, + issueFilterContext, issueFilters, liveIssueIds, normalizedSearchQuery, @@ -1367,6 +1390,15 @@ export function Inbox() { issueFilters: { ...previous.issueFilters, ...patch }, })); }, [updateFilterPreferences]); + useEffect(() => { + if (!experimentalSettingsLoaded || externalObjectsEnabled || issueFilters.externalObjectStatuses.length === 0) return; + updateIssueFilters({ externalObjectStatuses: [] }); + }, [ + experimentalSettingsLoaded, + externalObjectsEnabled, + issueFilters.externalObjectStatuses.length, + updateIssueFilters, + ]); const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => { updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value })); }, [updateFilterPreferences]); @@ -2027,6 +2059,7 @@ export function Inbox() { projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} + enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter buttonVariant="outline" iconOnly @@ -2124,6 +2157,7 @@ export function Inbox() { projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} + enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter buttonVariant="outline" iconOnly @@ -2334,6 +2368,7 @@ export function Inbox() { key={`issue:${issue.id}`} issue={issue} issueLinkState={issueLinkState} + externalObjectSummary={externalObjectSummaryByIssueId.get(issue.id) ?? null} selected={selected} className={ isArchiving diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 6a8d8dd5ff..9a8d837226 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -53,6 +53,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableConferenceRoomChat: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, + enableExternalObjects: false, enableTaskWatchdogs: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index e16ab3501a..9c769817f2 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -243,6 +243,7 @@ export function InstanceExperimentalSettings() { experimentalQuery.data?.enableExperimentalFileViewer === true; const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; + const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; const enableIssueGraphLivenessAutoRecovery = experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true; @@ -355,6 +356,24 @@ export function InstanceExperimentalSettings() { +
+
+
+

Enable External Objects

+

+ Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced + work objects. +

+
+ toggleMutation.mutate({ enableExternalObjects: !enableExternalObjects })} + disabled={toggleMutation.isPending} + aria-label="Toggle external objects experimental setting" + /> +
+
+
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 961fba00c0..43c894c9a8 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -957,6 +957,7 @@ describe("IssueDetail", () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, + enableExternalObjects: false, }); mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]); conferenceRoomChatFlag.enabled = true; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 342313bc0f..8ada911036 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -79,6 +79,7 @@ import { IssueOutputSection } from "../components/issue-output/IssueOutputSectio import { isImageAttachment } from "../lib/issue-attachments"; import { getPromotedOutputAttachmentIds } from "../lib/issue-output"; import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation"; +import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody"; import { IssuesList } from "../components/IssuesList"; import { AgentIcon } from "../components/AgentIconPicker"; import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary"; @@ -88,6 +89,7 @@ import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; import { IssueProperties } from "../components/IssueProperties"; import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews"; import { computePauseAffectsSummary } from "../lib/interrupt-handoff"; +import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects"; import { IssueRunLedger } from "../components/IssueRunLedger"; import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import type { MentionOption } from "../components/MarkdownEditor"; @@ -743,6 +745,7 @@ type IssueDetailChatTabProps = { assigneeUserId: string | null; onResumeFromBacklog?: () => Promise | void; resumeFromBacklogPending?: boolean; + externalReferences?: MarkdownExternalReferenceMap; }; const IssueDetailChatTab = memo(function IssueDetailChatTab({ @@ -804,6 +807,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ assigneeUserId, onResumeFromBacklog, resumeFromBacklogPending, + externalReferences, }: IssueDetailChatTabProps) { // Conference Room Chat experimental flag (PAP-136/PAP-139): ON renders the // NUX thread (bubbles, metadata rows, composer chrome); OFF renders the @@ -1028,6 +1032,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onResumeFromBacklog={onResumeFromBacklog} resumeFromBacklogPending={resumeFromBacklogPending} footer={footer} + externalReferences={externalReferences} />
); @@ -1048,6 +1053,7 @@ type IssueDetailActivityTabProps = { onCheckMonitorNow: () => void; checkingMonitorNow: boolean; handoffFocusSignal?: number; + externalReferences?: MarkdownExternalReferenceMap; }; function IssueDetailActivityTab({ @@ -1065,6 +1071,7 @@ function IssueDetailActivityTab({ onCheckMonitorNow, checkingMonitorNow, handoffFocusSignal = 0, + externalReferences, }: IssueDetailActivityTabProps) { const { data: activity, isLoading: activityLoading } = useQuery({ queryKey: queryKeys.issues.activity(issueId), @@ -1266,6 +1273,11 @@ function IssueDetailActivityTab({ }} />
+ {linkedApprovals && linkedApprovals.length > 0 && (
{linkedApprovals.map((approval) => ( @@ -1286,7 +1298,6 @@ function IssueDetailActivityTab({ ))}
)} - { if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) { return null; @@ -2859,6 +2871,10 @@ export function IssueDetail() { onAddSubIssue={openNewSubIssue} onUpdate={handleIssuePropertiesUpdate} hasActiveRun={resolvedHasActiveRun} + externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined} + externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined} + externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined} + onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined} /> ); return () => closePanel(); @@ -2871,6 +2887,11 @@ export function IssueDetail() { panelChildIssues, panelIssue, resolvedHasActiveRun, + externalObjectsState.isEnabled, + externalObjectsState.groups, + externalObjectsState.isLoading, + externalObjectsState.isError, + externalObjectsState.refetch, ]); const goToInboxShortcutArmedRef = useRef(false); @@ -3947,6 +3968,7 @@ export function IssueDetail() { multiline foldable mentions={mentionOptions} + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; @@ -4049,6 +4071,7 @@ export function IssueDetail() { feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackTermsUrl={FEEDBACK_TERMS_URL} mentions={mentionOptions} + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; @@ -4240,6 +4263,7 @@ export function IssueDetail() { resumeFromBacklogPending={ updateIssue.isPending && updateIssue.variables?.status === "todo" } + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} /> ) : null} @@ -4263,12 +4287,20 @@ export function IssueDetail() { }} onCheckMonitorNow={() => checkIssueMonitorNow.mutate()} checkingMonitorNow={checkIssueMonitorNow.isPending} + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} /> ) : null} - + {activePluginTab && ( @@ -4456,6 +4488,10 @@ export function IssueDetail() { onUpdate={(data) => updateIssue.mutate(data)} inline hasActiveRun={resolvedHasActiveRun} + externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined} + externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined} + externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined} + onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined} /> diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts index e7e2dadaf0..592ab9d9e9 100644 --- a/ui/storybook/.storybook/main.ts +++ b/ui/storybook/.storybook/main.ts @@ -24,6 +24,11 @@ const config: StorybookConfig = { alias: { "@": path.resolve(storybookConfigDir, "../../src"), lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/Lexical.mjs"), + // Vite's bundled `node:crypto` polyfill omits `createHash`, which + // `@paperclipai/shared/external-objects.ts` imports server-side. Use + // a no-op browser shim so the import resolves; the canonicalizer + // only runs server-side. + "node:crypto": path.resolve(storybookConfigDir, "node-crypto-browser-shim.ts"), }, }, }), diff --git a/ui/storybook/.storybook/node-crypto-browser-shim.ts b/ui/storybook/.storybook/node-crypto-browser-shim.ts new file mode 100644 index 0000000000..7a6cb01d73 --- /dev/null +++ b/ui/storybook/.storybook/node-crypto-browser-shim.ts @@ -0,0 +1,31 @@ +/** + * Browser-safe shim for the slice of `node:crypto` that + * `@paperclipai/shared/external-objects.ts` imports. + * + * The shared canonicalizer runs server-side and never executes in the browser, + * but the static `import { createHash } from "node:crypto"` is still pulled + * into Storybook's module graph. Vite's built-in `node:crypto` polyfill does + * not export `createHash`, so the build fails before our code runs. This shim + * provides a no-op implementation so the bundler can resolve the import; if a + * code path ever reaches it in the browser we throw loudly so we notice. + */ + +class BrowserHash { + update(_value: string): this { + throw new Error( + "createHash from node:crypto is not available in the browser bundle", + ); + } + + digest(_encoding: string): string { + throw new Error( + "createHash from node:crypto is not available in the browser bundle", + ); + } +} + +export function createHash(_algorithm: string): BrowserHash { + return new BrowserHash(); +} + +export default { createHash }; diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/chat-comments.stories.tsx index 041293f9b1..7ed50e53ef 100644 --- a/ui/storybook/stories/chat-comments.stories.tsx +++ b/ui/storybook/stories/chat-comments.stories.tsx @@ -4,6 +4,7 @@ import type { TranscriptEntry } from "@/adapters"; import type { LiveRunForIssue } from "@/api/heartbeats"; import { CommentThread } from "@/components/CommentThread"; import { IssueChatThread } from "@/components/IssueChatThread"; +import type { MarkdownExternalReferenceMap } from "@/components/MarkdownBody"; import { RunChatSurface } from "@/components/RunChatSurface"; import type { InlineEntityOption } from "@/components/InlineEntitySelector"; import type { MentionOption } from "@/components/MarkdownEditor"; @@ -376,14 +377,14 @@ const liveRunTranscript: TranscriptEntry[] = [ const issueChatComments: IssueChatComment[] = [ createComment({ id: "comment-issue-board", - body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.", + body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.\n\nFollow-up tracked in https://github.com/acme/web/pull/241 (merged) and https://github.com/acme/web/pull/243 (review pending).", createdAt: new Date("2026-04-20T13:44:00.000Z"), }), createComment({ id: "comment-issue-agent", authorAgentId: codexAgent.id, authorUserId: null, - body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.", + body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.\n\nFlaky CI lives in https://github.com/acme/web/pull/242 — re-running. Plain control link: https://random.example.com/path stays undecorated.", createdAt: new Date("2026-04-20T13:50:00.000Z"), runId: "run-issue-chat-01", runAgentId: codexAgent.id, @@ -592,10 +593,12 @@ function ThreadProps({ comments, queuedComments = [], timelineEvents = [], + externalReferences, }: { comments: StoryComment[]; queuedComments?: StoryComment[]; timelineEvents?: IssueTimelineEvent[]; + externalReferences?: MarkdownExternalReferenceMap; }) { return ( {}} + externalReferences={externalReferences} /> ); } +const externalReferenceComments: StoryComment[] = [ + createComment({ + id: "comment-external-board", + body: [ + "Tracking work that just landed:", + "", + "- Merged PR: https://github.com/acme/web/pull/241", + "- Awaiting review: https://github.com/acme/web/pull/243", + "- Auth-blocked: https://app.hubspot.com/leads/99", + "- Plain control link (no decoration): https://random.example.com/path", + ].join("\n"), + createdAt: new Date("2026-04-20T14:02:00.000Z"), + }), + createComment({ + id: "comment-external-agent", + authorAgentId: codexAgent.id, + authorUserId: null, + body: [ + "Confirmed handoff updated.", + "Failed CI on https://github.com/acme/web/pull/242 needs a rerun.", + "", + "```", + "Code-fenced URLs stay plain: https://github.com/acme/web/pull/241", + "```", + ].join("\n"), + createdAt: new Date("2026-04-20T14:05:00.000Z"), + runId: "run-external-01", + runAgentId: codexAgent.id, + }), +]; + +const externalReferences: MarkdownExternalReferenceMap = { + "https://github.com/acme/web/pull/241": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "succeeded", + liveness: "fresh", + statusLabel: "Merged", + displayTitle: "Add external refs", + }, + "https://github.com/acme/web/pull/242": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "failed", + liveness: "stale", + statusLabel: "CI failed", + displayTitle: "Flaky tests", + }, + "https://github.com/acme/web/pull/243": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "waiting", + liveness: "fresh", + statusLabel: "Awaiting review", + displayTitle: "Add liveness overlay", + }, + "https://app.hubspot.com/leads/99": { + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + statusLabel: "Reconnect", + displayTitle: "Acme deal", + }, +}; + function CommentThreadMatrix() { return (
@@ -635,6 +705,15 @@ function CommentThreadMatrix() { + + +
); @@ -709,6 +788,7 @@ function IssueChatMatrix() { includeSucceededRunsWithoutOutput onInterruptQueued={async () => {}} onCancelQueued={() => undefined} + externalReferences={externalReferences} />
diff --git a/ui/storybook/stories/external-objects.stories.tsx b/ui/storybook/stories/external-objects.stories.tsx new file mode 100644 index 0000000000..d70e211c3e --- /dev/null +++ b/ui/storybook/stories/external-objects.stories.tsx @@ -0,0 +1,720 @@ +import { useEffect, useRef, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + EXTERNAL_OBJECT_LIVENESS_STATES, + EXTERNAL_OBJECT_STATUS_CATEGORIES, + type ExternalObject, + type ExternalObjectLivenessState, + type ExternalObjectMention, + type ExternalObjectStatusCategory, + type ExternalObjectSummary, +} from "@paperclipai/shared"; +import { ExternalObjectPill } from "@/components/ExternalObjectPill"; +import { ExternalObjectStatusIcon } from "@/components/ExternalObjectStatusIcon"; +import { ExternalObjectStatusSummary } from "@/components/ExternalObjectStatusSummary"; +import { IssueFiltersPopover } from "@/components/IssueFiltersPopover"; +import { IssueProperties } from "@/components/IssueProperties"; +import { IssueRelatedWorkPanel } from "@/components/IssueRelatedWorkPanel"; +import { IssueRow } from "@/components/IssueRow"; +import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody"; +import { + countActiveIssueFilters, + defaultIssueFilterState, + type IssueFilterState, +} from "@/lib/issue-filters"; +import { + externalObjectCategoryLabel, + externalObjectFallbackTone, + externalObjectLivenessLabel, + externalObjectProviderLabel, + externalObjectTypeLabel, +} from "@/lib/external-objects"; +import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects"; +import { + storybookAgents, + storybookExecutionWorkspaces, + storybookIssueLabels, + storybookIssues, + storybookProjects, +} from "../fixtures/paperclipData"; + +function makeObject(args: { + id: string; + providerKey: string; + objectType: string; + statusCategory: ExternalObjectStatusCategory; + liveness: ExternalObjectLivenessState; + displayTitle?: string; + url: string; + statusLabel?: string; +}): ExternalObject { + return { + id: args.id, + companyId: "company-1", + providerKey: args.providerKey, + pluginId: null, + objectType: args.objectType, + externalId: args.id, + sanitizedCanonicalUrl: args.url, + canonicalIdentityHash: args.id, + displayTitle: args.displayTitle ?? null, + statusKey: args.statusCategory, + statusLabel: args.statusLabel ?? externalObjectCategoryLabel(args.statusCategory), + statusCategory: args.statusCategory, + statusTone: externalObjectFallbackTone(args.statusCategory), + liveness: args.liveness, + isTerminal: ["succeeded", "failed", "closed", "archived"].includes(args.statusCategory), + data: {}, + remoteVersion: null, + etag: null, + lastResolvedAt: "2026-04-24T22:45:00.000Z", + lastChangedAt: "2026-04-24T22:45:00.000Z", + lastErrorAt: args.liveness === "unreachable" ? "2026-04-24T22:50:00.000Z" : null, + nextRefreshAt: null, + lastErrorCode: null, + lastErrorMessage: null, + createdAt: "2026-04-24T20:00:00.000Z", + updatedAt: "2026-04-24T22:45:00.000Z", + }; +} + +function makeMention(args: { + id: string; + objectId: string; + sourceKind: ExternalObjectMention["sourceKind"]; + documentKey?: string | null; +}): ExternalObjectMention { + return { + id: args.id, + companyId: "company-1", + sourceIssueId: "issue-1", + sourceKind: args.sourceKind, + sourceRecordId: null, + documentKey: args.documentKey ?? null, + propertyKey: null, + matchedTextRedacted: null, + sanitizedDisplayUrl: "https://example.com/object", + canonicalIdentityHash: args.objectId, + canonicalIdentity: null, + objectId: args.objectId, + providerKey: null, + detectorKey: null, + objectType: null, + confidence: "exact", + createdByPluginId: null, + createdAt: "2026-04-24T20:00:00.000Z", + updatedAt: "2026-04-24T22:45:00.000Z", + }; +} + +function makeGroup(args: { + object: ExternalObject; + mentionCount?: number; + sourceLabels?: string[]; +}): IssueExternalObjectGroup { + return { + group: { + object: args.object, + mentions: [makeMention({ id: `${args.object.id}-m`, objectId: args.object.id, sourceKind: "description" })], + mentionCount: args.mentionCount ?? 1, + sourceLabels: args.sourceLabels ?? ["description"], + }, + pill: { + providerKey: args.object.providerKey, + objectType: args.object.objectType, + statusCategory: args.object.statusCategory, + liveness: args.object.liveness, + displayTitle: args.object.displayTitle, + statusLabel: args.object.statusLabel, + url: args.object.sanitizedCanonicalUrl, + }, + mentionCount: args.mentionCount ?? 1, + sourceLabels: args.sourceLabels ?? ["description"], + }; +} + +function StateMatrix() { + return ( +
+
+

External object status matrix

+

+ Every status category × liveness combination from the UX spec §6, used as the canonical + presentational reference for inline markdown, pills, properties, and rollups. +

+
+ + + + + {EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => ( + + ))} + + + + {EXTERNAL_OBJECT_STATUS_CATEGORIES.map((category) => ( + + + {EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => ( + + ))} + + ))} + +
Category{externalObjectLivenessLabel(liveness)}
{category} + +
+ +
+

Pills (host-rendered identity, no plugin React)

+
+ + + + + +
+
+
+ ); +} + +function inlineMarkdownStory() { + const references: MarkdownExternalReferenceMap = { + "https://github.com/acme/web/pull/241": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "succeeded", + liveness: "fresh", + statusLabel: "Merged", + displayTitle: "Add external refs", + }, + "https://github.com/acme/web/pull/242": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "failed", + liveness: "stale", + statusLabel: "CI failed", + displayTitle: "Flaky tests", + }, + "https://github.com/acme/web/pull/243": { + providerKey: "github", + objectType: "pull_request", + statusCategory: "waiting", + liveness: "fresh", + statusLabel: "Awaiting review", + displayTitle: "Add liveness overlay", + }, + "https://app.hubspot.com/leads/99": { + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + statusLabel: "Reconnect", + displayTitle: "Acme deal", + }, + "https://ci.example.com/runs/88421": { + providerKey: "ci", + objectType: "deployment", + statusCategory: "unreachable", + liveness: "unreachable", + statusLabel: "Unreachable", + displayTitle: "Prod-0412", + }, + }; + const markdown = `Status of recent integrations:\n\n- Merged PR: https://github.com/acme/web/pull/241\n- Stale failed CI: https://github.com/acme/web/pull/242\n- Awaiting review: https://github.com/acme/web/pull/243\n- Auth required: https://app.hubspot.com/leads/99\n- Unreachable deploy: https://ci.example.com/runs/88421\n- Unrelated control link: https://random.example.com/path\n\n\`\`\`\n# Code blocks must be left alone — https://github.com/acme/web/pull/241\n\`\`\`\n\nInline code stays plain too: \`https://github.com/acme/web/pull/241\`.`; + return ( +
+

Inline markdown decoration

+ {markdown} +
+ ); +} + +function relatedWorkStory() { + const externalObjects = [ + makeGroup({ + object: makeObject({ + id: "obj-1", + providerKey: "github", + objectType: "pull_request", + statusCategory: "failed", + liveness: "fresh", + displayTitle: "Add external refs", + url: "https://github.com/acme/web/pull/241", + }), + mentionCount: 4, + sourceLabels: ["description", "comments", "plan document"], + }), + makeGroup({ + object: makeObject({ + id: "obj-2", + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + displayTitle: "Acme deal", + url: "https://app.hubspot.com/leads/99", + }), + mentionCount: 1, + sourceLabels: ["External links property"], + }), + makeGroup({ + object: makeObject({ + id: "obj-3", + providerKey: "ci", + objectType: "deployment", + statusCategory: "running", + liveness: "fresh", + displayTitle: "deploy prod-0412", + url: "https://ci.example.com/runs/88421", + }), + mentionCount: 2, + sourceLabels: ["comments"], + }), + makeGroup({ + object: makeObject({ + id: "obj-4", + providerKey: "github", + objectType: "issue", + statusCategory: "succeeded", + liveness: "stale", + displayTitle: "Closed parent issue", + url: "https://github.com/acme/web/issues/15", + }), + mentionCount: 1, + sourceLabels: ["comments"], + }), + ]; + + return ( + + ); +} + +function projectsRollupStory() { + function summary(args: { + failed?: number; + waiting?: number; + running?: number; + succeeded?: number; + auth?: number; + stale?: number; + }): ExternalObjectSummary { + const objects: ExternalObjectSummary["objects"] = []; + function pushMany(category: ExternalObjectStatusCategory, count: number) { + for (let i = 0; i < count; i += 1) { + objects.push({ + id: `obj-${category}-${i}`, + providerKey: "github", + objectType: "pull_request", + displayTitle: null, + statusCategory: category, + statusTone: externalObjectFallbackTone(category), + liveness: args.stale && i % 2 === 0 ? "stale" : "fresh", + isTerminal: false, + }); + } + } + pushMany("failed", args.failed ?? 0); + pushMany("waiting", args.waiting ?? 0); + pushMany("running", args.running ?? 0); + pushMany("succeeded", args.succeeded ?? 0); + pushMany("auth_required", args.auth ?? 0); + const tones = objects.map((o) => o.statusTone); + const dominant: ExternalObjectSummary["highestSeverity"] = + tones.includes("danger") ? "danger" + : tones.includes("warning") ? "warning" + : tones.includes("info") ? "info" + : tones.includes("success") ? "success" + : "muted"; + const byStatusCategory: Record = {}; + for (const obj of objects) { + byStatusCategory[obj.statusCategory] = (byStatusCategory[obj.statusCategory] ?? 0) + 1; + } + return { + total: objects.length, + byStatusCategory, + byLiveness: { fresh: objects.length, stale: 0, auth_required: 0, unreachable: 0, unknown: 0 }, + highestSeverity: dominant, + staleCount: args.stale ?? 0, + authRequiredCount: args.auth ?? 0, + unreachableCount: 0, + objects, + }; + } + + const projects = [ + { name: "Paperclip App", color: "#6366f1", summary: summary({ failed: 3, running: 12 }) }, + { name: "Marketing site", color: "#22c55e", summary: summary({ waiting: 2 }) }, + { name: "Experimental", color: "#a855f7", summary: summary({ succeeded: 6 }) }, + { name: "Auth provider", color: "#f97316", summary: summary({ auth: 1 }) }, + ]; + return ( +
+
Projects (sidebar)
+
    + {projects.map((project) => ( +
  • + + {project.name} + +
  • + ))} +
+
+ ); +} + +function StateMatrixStory() { + return ( +
+ + {inlineMarkdownStory()} + {relatedWorkStory()} + {projectsRollupStory()} +
+ ); +} + +function makeIntegrationGroups(): IssueExternalObjectGroup[] { + return [ + makeGroup({ + object: makeObject({ + id: "obj-int-failed", + providerKey: "github", + objectType: "pull_request", + statusCategory: "failed", + liveness: "fresh", + displayTitle: "CI broken on main", + url: "https://github.com/acme/web/pull/241", + statusLabel: "CI failed", + }), + mentionCount: 4, + sourceLabels: ["Description", "3 comments"], + }), + makeGroup({ + object: makeObject({ + id: "obj-int-auth", + providerKey: "hubspot", + objectType: "lead", + statusCategory: "auth_required", + liveness: "auth_required", + displayTitle: "Acme deal — needs reconnect", + url: "https://app.hubspot.com/leads/99", + statusLabel: "Reconnect", + }), + mentionCount: 1, + sourceLabels: ["External links property"], + }), + makeGroup({ + object: makeObject({ + id: "obj-int-running", + providerKey: "ci", + objectType: "deployment", + statusCategory: "running", + liveness: "fresh", + displayTitle: "deploy prod-0412", + url: "https://ci.example.com/runs/88421", + statusLabel: "Running", + }), + mentionCount: 2, + sourceLabels: ["2 comments"], + }), + ]; +} + +function makeIntegrationSummary(): ExternalObjectSummary { + const objects = makeIntegrationGroups() + .map((entry) => entry.group.object) + .filter((object): object is ExternalObject => Boolean(object)) + .map((object) => ({ + id: object.id, + providerKey: object.providerKey, + objectType: object.objectType, + displayTitle: object.displayTitle, + statusCategory: object.statusCategory, + statusTone: object.statusTone, + liveness: object.liveness, + isTerminal: object.isTerminal, + })); + const tones = objects.map((o) => o.statusTone); + const dominant: ExternalObjectSummary["highestSeverity"] = + tones.includes("danger") ? "danger" + : tones.includes("warning") ? "warning" + : tones.includes("info") ? "info" + : tones.includes("success") ? "success" + : "muted"; + const byStatusCategory: Record = {}; + for (const o of objects) { + byStatusCategory[o.statusCategory] = (byStatusCategory[o.statusCategory] ?? 0) + 1; + } + return { + total: objects.length, + byStatusCategory, + byLiveness: { fresh: objects.length - 1, stale: 0, auth_required: 1, unreachable: 0, unknown: 0 }, + highestSeverity: dominant, + staleCount: 0, + authRequiredCount: 1, + unreachableCount: 0, + objects, + }; +} + +function PropertiesPanelDesktop() { + const issue = storybookIssues[0]!; + return ( +
+
+ Issue properties — desktop @ 1440×900 +
+ undefined} + onUpdate={() => undefined} + /> +
+ ); +} + +function PropertiesPanelMobile() { + const issue = storybookIssues[0]!; + return ( +
+
+ Issue properties — mobile sheet @ 390×844 +
+ undefined} + onUpdate={() => undefined} + inline + /> +
+ ); +} + +function RelatedWorkEmptyDesktop() { + return ( +
+
+ Related work — empty external objects (zero refs, empty copy visible) +
+ +
+ ); +} + +function SidebarMobileDrawer() { + const summary = makeIntegrationSummary(); + return ( +
+
Projects (mobile drawer)
+
    + {[ + { name: "Paperclip App", color: "#6366f1", summary }, + { name: "Marketing site", color: "#22c55e", summary: { ...summary, highestSeverity: "warning", byStatusCategory: { waiting: 2 }, total: 2, objects: [] } }, + { name: "Experimental", color: "#a855f7", summary: { ...summary, highestSeverity: "muted", byStatusCategory: {}, total: 0, objects: [] } }, + ].map((project) => ( +
  • + + {project.name} + +
  • + ))} +
+
+ ); +} + +function IssueListWithBadge() { + const summary = makeIntegrationSummary(); + return ( +
+
+ Issue list — desktop @ 1440×900 (badge + control row) +
+
+ {storybookIssues.slice(0, 2).map((issue, index) => ( + {issue.priority} + } + /> + ))} +
+
+ ); +} + +function FilterPopoverWithExternalChecked() { + const [state, setState] = useState({ + ...defaultIssueFilterState, + externalObjectStatuses: ["failed", "auth_required"], + }); + const triggerRef = useRef(null); + + useEffect(() => { + const timer = window.setTimeout(() => { + triggerRef.current?.querySelector("button")?.click(); + }, 150); + return () => window.clearTimeout(timer); + }, []); + + return ( +
+
+ setState((current) => ({ ...current, ...patch }))} + activeFilterCount={countActiveIssueFilters(state, true)} + agents={storybookAgents.map((agent) => ({ id: agent.id, name: agent.name }))} + projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))} + labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId="user-board" + enableRoutineVisibilityFilter + buttonVariant="outline" + workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))} + creators={[ + { id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" }, + ]} + /> +
+
+ ); +} + +function IntegrationSurfacesStory() { + return ( +
+ + + + + + +
+ ); +} + +const meta = { + title: "Foundations/External Objects", + component: StateMatrixStory, + parameters: { + docs: { + description: { + component: + "External-object surface gallery: the §6 state matrix, the inline markdown decoration, the related-work section, and the project-rollup sidebar marker. Mirrors the Phase 6 acceptance set from the UX spec.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FullSurface: Story = {}; + +export const PropertiesRowDesktop: StoryObj = { + render: () => , +}; + +export const PropertiesRowMobileSheet: StoryObj = { + render: () => , +}; + +export const RelatedWorkEmpty: StoryObj = { + render: () => , +}; + +export const SidebarMobile: StoryObj = { + render: () => , +}; + +export const IssueListRow: StoryObj = { + render: () => , +}; + +export const FilterPopoverOpen: StoryObj = { + render: () => , +}; + +export const IntegrationSurfaces: StoryObj = { + render: () => , +};