mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
feat(briefs): add deterministic card service
This commit is contained in:
1 parent
96f0279e08
commit
602902ca3d
11 files changed
+1806
No files matched your search
@@ -0,0 +1,114 @@
|
||||
CREATE TABLE plugin_briefs_37182b0291.briefs_cards (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
slug text NOT NULL,
|
||||
title text NOT NULL,
|
||||
grouping_description text NOT NULL,
|
||||
root_issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
state text NOT NULL CHECK (state IN ('error', 'blocked', 'waiting-user', 'waiting-reviewer', 'live', 'done', 'stale')),
|
||||
summary_status text NOT NULL CHECK (summary_status IN ('ok', 'pending', 'fallback')),
|
||||
pinned boolean NOT NULL DEFAULT false,
|
||||
hidden boolean NOT NULL DEFAULT false,
|
||||
stale_at timestamptz NOT NULL,
|
||||
expires_at timestamptz,
|
||||
latest_snapshot_id uuid,
|
||||
last_meaningful_event_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, user_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_briefs_37182b0291.briefs_card_sources (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
card_id uuid NOT NULL REFERENCES plugin_briefs_37182b0291.briefs_cards(id) ON DELETE CASCADE,
|
||||
source_kind text NOT NULL CHECK (source_kind IN ('issue_tree', 'issue', 'comment', 'run', 'document', 'work_product', 'interaction', 'activity_event', 'approval')),
|
||||
source_id text NOT NULL,
|
||||
issue_id uuid REFERENCES public.issues(id) ON DELETE SET NULL,
|
||||
identifier text,
|
||||
title_line text NOT NULL,
|
||||
right_tag text NOT NULL,
|
||||
link_path text NOT NULL,
|
||||
is_intra_tree_blocked boolean,
|
||||
event_at timestamptz NOT NULL,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (card_id, source_kind, source_id)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_briefs_37182b0291.briefs_card_snapshots (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
card_id uuid NOT NULL REFERENCES plugin_briefs_37182b0291.briefs_cards(id) ON DELETE CASCADE,
|
||||
summary_paragraph text,
|
||||
summary_status text NOT NULL CHECK (summary_status IN ('ok', 'pending', 'fallback')),
|
||||
summary_model text,
|
||||
summary_tokens_in integer,
|
||||
summary_tokens_out integer,
|
||||
summary_failure_reason text CHECK (summary_failure_reason IS NULL OR summary_failure_reason IN ('model_error', 'truncation_failed', 'budget_capped', 'safety_block')),
|
||||
task_rows jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_source_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
generated_by_agent_id uuid,
|
||||
generated_by_run_id uuid,
|
||||
deterministic_state_inputs jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE plugin_briefs_37182b0291.briefs_cards
|
||||
ADD CONSTRAINT briefs_cards_latest_snapshot_fk
|
||||
FOREIGN KEY (latest_snapshot_id)
|
||||
REFERENCES plugin_briefs_37182b0291.briefs_card_snapshots(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
CREATE TABLE plugin_briefs_37182b0291.briefs_user_preferences (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
cadence text NOT NULL DEFAULT 'hourly' CHECK (cadence IN ('manual', 'hourly', 'daily')),
|
||||
retention_days integer NOT NULL DEFAULT 7 CHECK (retention_days > 0),
|
||||
done_retention_hours integer NOT NULL DEFAULT 72 CHECK (done_retention_hours > 0),
|
||||
stale_after_days integer NOT NULL DEFAULT 7 CHECK (stale_after_days > 0),
|
||||
max_unpinned_cards integer NOT NULL DEFAULT 30 CHECK (max_unpinned_cards > 0),
|
||||
scope text NOT NULL DEFAULT 'user' CHECK (scope IN ('user')),
|
||||
settings jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE plugin_briefs_37182b0291.briefs_cursors (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL,
|
||||
cursor_key text NOT NULL,
|
||||
cursor_kind text NOT NULL,
|
||||
last_seen_at timestamptz,
|
||||
overlap_window_start_at timestamptz,
|
||||
dedupe_state jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (company_id, user_id, cursor_key)
|
||||
);
|
||||
|
||||
CREATE INDEX briefs_cards_company_user_state_idx
|
||||
ON plugin_briefs_37182b0291.briefs_cards (company_id, user_id, hidden, state, last_meaningful_event_at DESC);
|
||||
|
||||
CREATE INDEX briefs_cards_root_issue_idx
|
||||
ON plugin_briefs_37182b0291.briefs_cards (company_id, root_issue_id);
|
||||
|
||||
CREATE INDEX briefs_card_sources_card_event_idx
|
||||
ON plugin_briefs_37182b0291.briefs_card_sources (card_id, event_at DESC);
|
||||
|
||||
CREATE INDEX briefs_card_sources_company_source_idx
|
||||
ON plugin_briefs_37182b0291.briefs_card_sources (company_id, source_kind, source_id);
|
||||
|
||||
CREATE INDEX briefs_card_snapshots_card_created_idx
|
||||
ON plugin_briefs_37182b0291.briefs_card_snapshots (card_id, created_at DESC);
|
||||
|
||||
CREATE INDEX briefs_cursors_company_user_kind_idx
|
||||
ON plugin_briefs_37182b0291.briefs_cursors (company_id, user_id, cursor_kind);
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-briefs",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party Briefs plugin data model and deterministic briefing card service",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/plugins/plugin-briefs"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations"
|
||||
],
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"briefs",
|
||||
"briefing"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs",
|
||||
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs",
|
||||
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { z } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const briefCardStateSchema = z.enum([
|
||||
"error",
|
||||
"blocked",
|
||||
"waiting-user",
|
||||
"waiting-reviewer",
|
||||
"live",
|
||||
"done",
|
||||
"stale",
|
||||
]);
|
||||
|
||||
export const briefSummaryStatusSchema = z.enum(["ok", "pending", "fallback"]);
|
||||
|
||||
export const briefSummaryFailureReasonSchema = z.enum([
|
||||
"model_error",
|
||||
"truncation_failed",
|
||||
"budget_capped",
|
||||
"safety_block",
|
||||
]);
|
||||
|
||||
export const briefSourceKindSchema = z.enum([
|
||||
"issue_tree",
|
||||
"issue",
|
||||
"comment",
|
||||
"run",
|
||||
"document",
|
||||
"work_product",
|
||||
"interaction",
|
||||
"activity_event",
|
||||
"approval",
|
||||
]);
|
||||
|
||||
export const briefTaskRowSchema = z.object({
|
||||
kind: briefSourceKindSchema.exclude(["issue_tree", "activity_event", "work_product"]),
|
||||
sourceId: z.string().min(1),
|
||||
issueId: z.string().min(1).nullable(),
|
||||
identifier: z.string().min(1).nullable(),
|
||||
titleLine: z.string().min(1).max(120),
|
||||
rightTag: z.string().min(1).max(40),
|
||||
linkPath: z.string().min(1),
|
||||
isIntraTreeBlocked: z.boolean().nullable(),
|
||||
eventAt: z.string().min(1),
|
||||
}).strict();
|
||||
|
||||
export const briefCardSourceSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
companyId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
cardId: z.string().min(1),
|
||||
sourceKind: briefSourceKindSchema,
|
||||
sourceId: z.string().min(1),
|
||||
issueId: z.string().min(1).nullable(),
|
||||
identifier: z.string().min(1).nullable(),
|
||||
titleLine: z.string().min(1).max(160),
|
||||
rightTag: z.string().min(1).max(60),
|
||||
linkPath: z.string().min(1),
|
||||
isIntraTreeBlocked: z.boolean().nullable(),
|
||||
eventAt: z.string().min(1),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
}).strict();
|
||||
|
||||
export const briefSnapshotSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
companyId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
cardId: z.string().min(1),
|
||||
summaryParagraph: z.string().max(260).nullable(),
|
||||
summaryStatus: briefSummaryStatusSchema,
|
||||
summaryModel: z.string().min(1).nullable(),
|
||||
summaryTokensIn: z.number().int().nonnegative().nullable(),
|
||||
summaryTokensOut: z.number().int().nonnegative().nullable(),
|
||||
summaryFailureReason: briefSummaryFailureReasonSchema.nullable(),
|
||||
taskRows: z.array(briefTaskRowSchema).max(3),
|
||||
evidenceSourceIds: z.array(z.string().min(1)),
|
||||
generatedByAgentId: z.string().min(1).nullable(),
|
||||
generatedByRunId: z.string().min(1).nullable(),
|
||||
deterministicStateInputs: z.record(z.string(), z.unknown()).default({}),
|
||||
createdAt: z.string().min(1),
|
||||
}).strict();
|
||||
|
||||
export const briefCardSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
companyId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
slug: z.string().min(1).max(120),
|
||||
title: z.string().min(1).max(90),
|
||||
groupingDescription: z.string().min(1).max(500),
|
||||
rootIssueId: z.string().min(1).nullable(),
|
||||
state: briefCardStateSchema,
|
||||
summaryStatus: briefSummaryStatusSchema,
|
||||
pinned: z.boolean(),
|
||||
hidden: z.boolean(),
|
||||
staleAt: z.string().min(1),
|
||||
expiresAt: z.string().min(1).nullable(),
|
||||
latestSnapshotId: z.string().min(1).nullable(),
|
||||
lastMeaningfulEventAt: z.string().min(1),
|
||||
snapshot: briefSnapshotSchema,
|
||||
sources: z.array(briefCardSourceSchema),
|
||||
moreSourceCount: z.number().int().nonnegative(),
|
||||
}).strict();
|
||||
|
||||
export const briefPreferencesSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
cadence: z.enum(["manual", "hourly", "daily"]).default("hourly"),
|
||||
retentionDays: z.number().int().positive().default(7),
|
||||
doneRetentionHours: z.number().int().positive().default(72),
|
||||
staleAfterDays: z.number().int().positive().default(7),
|
||||
maxUnpinnedCards: z.number().int().positive().default(30),
|
||||
scope: z.literal("user").default("user"),
|
||||
}).strict();
|
||||
|
||||
export const listBriefCardsInputSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
includeHidden: z.boolean().optional().default(false),
|
||||
limit: z.number().int().positive().max(100).optional().default(50),
|
||||
}).strict();
|
||||
|
||||
export const pinBriefCardInputSchema = z.object({
|
||||
companyId: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
cardId: z.string().min(1),
|
||||
pinned: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
export const updateBriefPreferencesInputSchema = briefPreferencesSchema.partial({
|
||||
cadence: true,
|
||||
retentionDays: true,
|
||||
doneRetentionHours: true,
|
||||
staleAfterDays: true,
|
||||
maxUnpinnedCards: true,
|
||||
scope: true,
|
||||
}).required({
|
||||
companyId: true,
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const briefCursorEventSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
eventAt: z.string().min(1),
|
||||
fingerprint: z.string().min(1).optional(),
|
||||
}).strict();
|
||||
|
||||
export type BriefCardState = z.infer<typeof briefCardStateSchema>;
|
||||
export type BriefSummaryStatus = z.infer<typeof briefSummaryStatusSchema>;
|
||||
export type BriefSummaryFailureReason = z.infer<typeof briefSummaryFailureReasonSchema>;
|
||||
export type BriefSourceKind = z.infer<typeof briefSourceKindSchema>;
|
||||
export type BriefTaskRow = z.infer<typeof briefTaskRowSchema>;
|
||||
export type BriefCardSource = z.infer<typeof briefCardSourceSchema>;
|
||||
export type BriefSnapshot = z.infer<typeof briefSnapshotSchema>;
|
||||
export type BriefCard = z.infer<typeof briefCardSchema>;
|
||||
export type BriefPreferences = z.infer<typeof briefPreferencesSchema>;
|
||||
export type ListBriefCardsInput = z.infer<typeof listBriefCardsInputSchema>;
|
||||
export type PinBriefCardInput = z.infer<typeof pinBriefCardInputSchema>;
|
||||
export type UpdateBriefPreferencesInput = z.infer<typeof updateBriefPreferencesInputSchema>;
|
||||
export type BriefCursorEvent = z.infer<typeof briefCursorEventSchema>;
|
||||
@@ -0,0 +1,766 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
BriefCard,
|
||||
BriefCardSource,
|
||||
BriefCardState,
|
||||
BriefCursorEvent,
|
||||
BriefPreferences,
|
||||
BriefSnapshot,
|
||||
BriefSummaryFailureReason,
|
||||
BriefSummaryStatus,
|
||||
BriefTaskRow,
|
||||
} from "./contracts.js";
|
||||
|
||||
export type BriefIssueStatus =
|
||||
| "backlog"
|
||||
| "todo"
|
||||
| "in_progress"
|
||||
| "in_review"
|
||||
| "done"
|
||||
| "blocked"
|
||||
| "cancelled";
|
||||
|
||||
export type BriefsIssueInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
parentId: string | null;
|
||||
title: string;
|
||||
identifier: string | null;
|
||||
status: BriefIssueStatus | string;
|
||||
priority?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
updatedByUserId?: string | null;
|
||||
activeRecoveryAction?: unknown | null;
|
||||
blockerAttention?: { reason?: string | null } | null;
|
||||
executionState?: {
|
||||
currentParticipant?: { type?: string; userId?: string | null; agentId?: string | null } | null;
|
||||
returnAssignee?: { type?: string; userId?: string | null; agentId?: string | null } | null;
|
||||
} | null;
|
||||
createdAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
completedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsRelationInput = {
|
||||
blockedBy?: Array<{
|
||||
id: string;
|
||||
companyId?: string | null;
|
||||
identifier?: string | null;
|
||||
title?: string | null;
|
||||
status?: BriefIssueStatus | string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type BriefsRunInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string | null;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
startedAt?: Date | string | null;
|
||||
finishedAt?: Date | string | null;
|
||||
createdAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsCommentInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
authorUserId?: string | null;
|
||||
body?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsDocumentInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
key: string;
|
||||
title?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
updatedByUserId?: string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsInteractionInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
kind: "request_confirmation" | "ask_user_questions" | "suggest_tasks" | string;
|
||||
status: "pending" | "answered" | "accepted" | "rejected" | "cancelled" | string;
|
||||
targetUserId?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsApprovalInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
status: "pending_approval" | "approved" | "rejected" | string;
|
||||
reviewerUserId?: string | null;
|
||||
decidedByUserId?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
decidedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsWorkProductInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
title: string;
|
||||
status?: string | null;
|
||||
createdAt?: Date | string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsActivityEventInput = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId?: string | null;
|
||||
action: string;
|
||||
createdAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type BriefsSourceBundle = {
|
||||
companyId: string;
|
||||
userId: string;
|
||||
rootIssueId: string;
|
||||
issues: BriefsIssueInput[];
|
||||
relations?: Record<string, BriefsRelationInput>;
|
||||
activeRuns?: Record<string, BriefsRunInput[]>;
|
||||
runs?: BriefsRunInput[];
|
||||
comments?: BriefsCommentInput[];
|
||||
documents?: BriefsDocumentInput[];
|
||||
interactions?: BriefsInteractionInput[];
|
||||
approvals?: BriefsApprovalInput[];
|
||||
workProducts?: BriefsWorkProductInput[];
|
||||
activityEvents?: BriefsActivityEventInput[];
|
||||
relevantAgentIds?: string[];
|
||||
groupingDescription?: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
export type DeterministicBriefOptions = {
|
||||
now?: Date | string;
|
||||
pinned?: boolean;
|
||||
hidden?: boolean;
|
||||
summaryStatus?: BriefSummaryStatus;
|
||||
summaryParagraph?: string | null;
|
||||
summaryFailureReason?: BriefSummaryFailureReason | null;
|
||||
summaryModel?: string | null;
|
||||
summaryTokensIn?: number | null;
|
||||
summaryTokensOut?: number | null;
|
||||
generatedByAgentId?: string | null;
|
||||
generatedByRunId?: string | null;
|
||||
preferences?: Partial<BriefPreferences>;
|
||||
idFactory?: () => string;
|
||||
};
|
||||
|
||||
export type BriefCursorDedupeResult = {
|
||||
freshEvents: BriefCursorEvent[];
|
||||
dedupeState: string[];
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_RETENTION_DAYS = 7;
|
||||
const DEFAULT_DONE_RETENTION_HOURS = 72;
|
||||
const DEFAULT_STALE_AFTER_DAYS = 7;
|
||||
const DEFAULT_DISCOVERY_WINDOW_DAYS = 14;
|
||||
const LIVE_WINDOW_HOURS = 6;
|
||||
const FAILED_RUN_WINDOW_HOURS = 24;
|
||||
const MAX_DEDUPE_STATE = 500;
|
||||
|
||||
const BLOCKER_ATTENTION_REASONS = new Set([
|
||||
"owner_paused",
|
||||
"recovery_required",
|
||||
"external_wait",
|
||||
"needs_attention",
|
||||
"stalled",
|
||||
]);
|
||||
|
||||
function asTime(value: Date | string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : null;
|
||||
}
|
||||
|
||||
function toIso(value: number | Date | string): string {
|
||||
const date = typeof value === "number" ? new Date(value) : value instanceof Date ? value : new Date(value);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function addMs(time: number, amount: number): number {
|
||||
return time + amount;
|
||||
}
|
||||
|
||||
function days(value: number): number {
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function hours(value: number): number {
|
||||
return value * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
if (compact.length <= max) return compact;
|
||||
return `${compact.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function slugifyBriefGrouping(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 96) || "brief";
|
||||
}
|
||||
|
||||
function issueHref(issue: Pick<BriefsIssueInput, "id" | "identifier">): string {
|
||||
return `/PAP/issues/${issue.identifier ?? issue.id}`;
|
||||
}
|
||||
|
||||
function sourceIssue(bundle: BriefsSourceBundle, issueId: string | null | undefined): BriefsIssueInput | null {
|
||||
if (!issueId) return null;
|
||||
return bundle.issues.find((issue) => issue.id === issueId) ?? null;
|
||||
}
|
||||
|
||||
function sourceLink(bundle: BriefsSourceBundle, kind: string, sourceId: string, issueId: string | null, key?: string): string {
|
||||
const issue = sourceIssue(bundle, issueId);
|
||||
if (kind === "run") return `/PAP/runs/${sourceId}`;
|
||||
if (!issue) return `/PAP/issues/${issueId ?? sourceId}`;
|
||||
if (kind === "comment") return `${issueHref(issue)}#comment-${sourceId}`;
|
||||
if (kind === "document") return `${issueHref(issue)}#document-${key ?? sourceId}`;
|
||||
if (kind === "interaction") return `${issueHref(issue)}#interaction-${sourceId}`;
|
||||
if (kind === "approval") return `/PAP/approvals/${sourceId}`;
|
||||
if (kind === "work_product") return `${issueHref(issue)}#work-product-${sourceId}`;
|
||||
return issueHref(issue);
|
||||
}
|
||||
|
||||
function eventTime(...values: Array<Date | string | null | undefined>): number {
|
||||
return Math.max(0, ...values.map(asTime).filter((time): time is number => time != null));
|
||||
}
|
||||
|
||||
function issueEventTime(issue: BriefsIssueInput): number {
|
||||
return eventTime(issue.completedAt, issue.updatedAt, issue.createdAt);
|
||||
}
|
||||
|
||||
function runEventTime(run: BriefsRunInput): number {
|
||||
return eventTime(run.finishedAt, run.startedAt, run.createdAt);
|
||||
}
|
||||
|
||||
function assertCompanyScope(bundle: BriefsSourceBundle): void {
|
||||
const issueIds = new Set(bundle.issues.map((issue) => issue.id));
|
||||
const check = (label: string, companyId: string, sourceId: string) => {
|
||||
if (companyId !== bundle.companyId) {
|
||||
throw new Error(`Briefs source ${label}:${sourceId} belongs to another company`);
|
||||
}
|
||||
};
|
||||
|
||||
for (const issue of bundle.issues) check("issue", issue.companyId, issue.id);
|
||||
for (const run of [...Object.values(bundle.activeRuns ?? {}).flat(), ...(bundle.runs ?? [])]) check("run", run.companyId, run.id);
|
||||
for (const comment of bundle.comments ?? []) check("comment", comment.companyId, comment.id);
|
||||
for (const document of bundle.documents ?? []) check("document", document.companyId, document.id);
|
||||
for (const interaction of bundle.interactions ?? []) check("interaction", interaction.companyId, interaction.id);
|
||||
for (const approval of bundle.approvals ?? []) check("approval", approval.companyId, approval.id);
|
||||
for (const workProduct of bundle.workProducts ?? []) check("work_product", workProduct.companyId, workProduct.id);
|
||||
for (const event of bundle.activityEvents ?? []) check("activity_event", event.companyId, event.id);
|
||||
|
||||
for (const [issueId, relation] of Object.entries(bundle.relations ?? {})) {
|
||||
if (!issueIds.has(issueId)) {
|
||||
throw new Error(`Briefs relation references issue outside the source bundle: ${issueId}`);
|
||||
}
|
||||
for (const blocker of relation.blockedBy ?? []) {
|
||||
if (blocker.companyId && blocker.companyId !== bundle.companyId) {
|
||||
throw new Error(`Briefs blocker ${blocker.id} belongs to another company`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRootIssue(bundle: BriefsSourceBundle): BriefsIssueInput {
|
||||
const root = bundle.issues.find((issue) => issue.id === bundle.rootIssueId);
|
||||
if (!root) throw new Error(`Root issue not found in Briefs source bundle: ${bundle.rootIssueId}`);
|
||||
return root;
|
||||
}
|
||||
|
||||
function getIssueIds(bundle: BriefsSourceBundle): Set<string> {
|
||||
return new Set(bundle.issues.map((issue) => issue.id));
|
||||
}
|
||||
|
||||
function activeRuns(bundle: BriefsSourceBundle): BriefsRunInput[] {
|
||||
return [
|
||||
...Object.values(bundle.activeRuns ?? {}).flat(),
|
||||
...(bundle.runs ?? []).filter((run) => ["queued", "running", "in_progress"].includes(run.status)),
|
||||
];
|
||||
}
|
||||
|
||||
function failedRunsWithinWindow(bundle: BriefsSourceBundle, nowMs: number): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const run of bundle.runs ?? []) {
|
||||
if (!run.issueId || !["failed", "error"].includes(run.status)) continue;
|
||||
const at = runEventTime(run);
|
||||
if (!at || nowMs - at > hours(FAILED_RUN_WINDOW_HOURS)) continue;
|
||||
counts.set(run.issueId, (counts.get(run.issueId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function hasOutOfTreeBlocker(issue: BriefsIssueInput, relation: BriefsRelationInput | undefined, treeIssueIds: Set<string>): boolean {
|
||||
if (issue.status !== "blocked") return false;
|
||||
for (const blocker of relation?.blockedBy ?? []) {
|
||||
if (["done", "cancelled"].includes(String(blocker.status))) continue;
|
||||
if (!treeIssueIds.has(blocker.id)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function issueHasBlockingAttention(issue: BriefsIssueInput): boolean {
|
||||
const reason = issue.blockerAttention?.reason;
|
||||
return Boolean(reason && BLOCKER_ATTENTION_REASONS.has(reason));
|
||||
}
|
||||
|
||||
function participantIsUser(
|
||||
participant: BriefsIssueInput["executionState"] extends infer T
|
||||
? T extends { currentParticipant?: infer P } ? P : never
|
||||
: never,
|
||||
userId: string,
|
||||
): boolean {
|
||||
return Boolean(participant && typeof participant === "object" && "type" in participant && participant.type === "user" && participant.userId === userId);
|
||||
}
|
||||
|
||||
function issueWaitingOnUser(issue: BriefsIssueInput, userId: string): boolean {
|
||||
return issue.assigneeUserId === userId
|
||||
|| participantIsUser(issue.executionState?.currentParticipant ?? null, userId)
|
||||
|| participantIsUser(issue.executionState?.returnAssignee ?? null, userId);
|
||||
}
|
||||
|
||||
function issueHasTypedReviewer(issue: BriefsIssueInput): boolean {
|
||||
const participant = issue.executionState?.currentParticipant;
|
||||
return issue.status === "in_review" && Boolean(participant?.type === "user" || participant?.type === "agent");
|
||||
}
|
||||
|
||||
function pendingInteractionForUser(interaction: BriefsInteractionInput, userId: string): boolean {
|
||||
return interaction.status === "pending" && (!interaction.targetUserId || interaction.targetUserId === userId);
|
||||
}
|
||||
|
||||
function pendingApprovalForUser(approval: BriefsApprovalInput, userId: string): boolean {
|
||||
return approval.status === "pending_approval" && approval.reviewerUserId === userId;
|
||||
}
|
||||
|
||||
function pendingApprovalForOtherReviewer(approval: BriefsApprovalInput, userId: string): boolean {
|
||||
return approval.status === "pending_approval" && approval.reviewerUserId !== userId;
|
||||
}
|
||||
|
||||
function lastMeaningfulEventMs(bundle: BriefsSourceBundle): number {
|
||||
return Math.max(
|
||||
...bundle.issues.map(issueEventTime),
|
||||
...Object.values(bundle.activeRuns ?? {}).flat().map(runEventTime),
|
||||
...(bundle.runs ?? []).map(runEventTime),
|
||||
...(bundle.comments ?? []).map((comment) => eventTime(comment.createdAt)),
|
||||
...(bundle.documents ?? []).map((document) => eventTime(document.updatedAt)),
|
||||
...(bundle.interactions ?? []).map((interaction) => eventTime(interaction.updatedAt, interaction.createdAt)),
|
||||
...(bundle.approvals ?? []).map((approval) => eventTime(approval.decidedAt, approval.createdAt)),
|
||||
...(bundle.workProducts ?? []).map((workProduct) => eventTime(workProduct.updatedAt, workProduct.createdAt)),
|
||||
...(bundle.activityEvents ?? []).map((activity) => eventTime(activity.createdAt)),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveBriefCardState(bundle: BriefsSourceBundle, options: { now?: Date | string; preferences?: Partial<BriefPreferences> } = {}): {
|
||||
state: BriefCardState;
|
||||
inputs: Record<string, unknown>;
|
||||
} {
|
||||
assertCompanyScope(bundle);
|
||||
const nowMs = asTime(options.now ?? new Date()) ?? Date.now();
|
||||
const root = getRootIssue(bundle);
|
||||
const treeIssueIds = getIssueIds(bundle);
|
||||
const failedRunCounts = failedRunsWithinWindow(bundle, nowMs);
|
||||
const lastEventMs = lastMeaningfulEventMs(bundle);
|
||||
const doneRetentionHours = options.preferences?.doneRetentionHours ?? DEFAULT_DONE_RETENTION_HOURS;
|
||||
|
||||
const hasRecoveryAction = bundle.issues.some((issue) => Boolean(issue.activeRecoveryAction));
|
||||
const hasFailedRunLoop = [...failedRunCounts.values()].some((count) => count >= 3);
|
||||
const hasBlocked = bundle.issues.some((issue) => hasOutOfTreeBlocker(issue, bundle.relations?.[issue.id], treeIssueIds) || issueHasBlockingAttention(issue));
|
||||
const hasWaitingUser = bundle.issues.some((issue) => issueWaitingOnUser(issue, bundle.userId))
|
||||
|| (bundle.interactions ?? []).some((interaction) => pendingInteractionForUser(interaction, bundle.userId))
|
||||
|| (bundle.approvals ?? []).some((approval) => pendingApprovalForUser(approval, bundle.userId));
|
||||
const hasWaitingReviewer = bundle.issues.some((issue) => issueHasTypedReviewer(issue) && !issueWaitingOnUser(issue, bundle.userId))
|
||||
|| (bundle.approvals ?? []).some((approval) => pendingApprovalForOtherReviewer(approval, bundle.userId));
|
||||
const hasLive = (activeRuns(bundle).length > 0 || bundle.issues.some((issue) => issue.status === "in_progress"))
|
||||
&& lastEventMs > 0
|
||||
&& nowMs - lastEventMs <= hours(LIVE_WINDOW_HOURS);
|
||||
const doneAt = asTime(root.completedAt) ?? (root.status === "done" ? issueEventTime(root) : null);
|
||||
const isRecentlyDone = root.status === "done" && doneAt != null && nowMs - doneAt <= hours(doneRetentionHours);
|
||||
|
||||
const inputs = {
|
||||
hasRecoveryAction,
|
||||
hasFailedRunLoop,
|
||||
hasBlocked,
|
||||
hasWaitingUser,
|
||||
hasWaitingReviewer,
|
||||
hasLive,
|
||||
isRecentlyDone,
|
||||
lastMeaningfulEventAt: lastEventMs ? toIso(lastEventMs) : null,
|
||||
};
|
||||
|
||||
if (hasRecoveryAction || hasFailedRunLoop) return { state: "error", inputs };
|
||||
if (hasBlocked) return { state: "blocked", inputs };
|
||||
if (hasWaitingUser) return { state: "waiting-user", inputs };
|
||||
if (hasWaitingReviewer) return { state: "waiting-reviewer", inputs };
|
||||
if (hasLive) return { state: "live", inputs };
|
||||
if (isRecentlyDone) return { state: "done", inputs };
|
||||
return { state: "stale", inputs };
|
||||
}
|
||||
|
||||
function sourcePriority(source: BriefCardSource): number {
|
||||
if (source.rightTag === "blocked") return 0;
|
||||
if (source.rightTag === "asked you") return 1;
|
||||
if (source.rightTag === "in_review" || source.rightTag === "approval") return 2;
|
||||
if (source.rightTag === "running" || source.rightTag === "in_progress") return 3;
|
||||
if (source.rightTag === "failed") return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
function toTaskRow(source: BriefCardSource): BriefTaskRow | null {
|
||||
if (!["issue", "run", "comment", "document", "interaction", "approval"].includes(source.sourceKind)) return null;
|
||||
return {
|
||||
kind: source.sourceKind as BriefTaskRow["kind"],
|
||||
sourceId: source.sourceId,
|
||||
issueId: source.issueId,
|
||||
identifier: source.identifier,
|
||||
titleLine: truncate(source.titleLine, 120),
|
||||
rightTag: source.rightTag,
|
||||
linkPath: source.linkPath,
|
||||
isIntraTreeBlocked: source.isIntraTreeBlocked,
|
||||
eventAt: source.eventAt,
|
||||
};
|
||||
}
|
||||
|
||||
function issueRightTag(issue: BriefsIssueInput, bundle: BriefsSourceBundle, treeIssueIds: Set<string>): { tag: string; intraTreeBlocked: boolean | null } {
|
||||
if (issue.status === "blocked") {
|
||||
const relation = bundle.relations?.[issue.id];
|
||||
const blockers = relation?.blockedBy ?? [];
|
||||
const hasOnlyInTreeBlockers = blockers.length > 0 && blockers.every((blocker) => treeIssueIds.has(blocker.id));
|
||||
return { tag: "blocked", intraTreeBlocked: hasOnlyInTreeBlockers };
|
||||
}
|
||||
if (issueWaitingOnUser(issue, bundle.userId)) return { tag: "asked you", intraTreeBlocked: null };
|
||||
return { tag: String(issue.status), intraTreeBlocked: null };
|
||||
}
|
||||
|
||||
function buildSources(bundle: BriefsSourceBundle, cardId: string, idFactory: () => string): BriefCardSource[] {
|
||||
const treeIssueIds = getIssueIds(bundle);
|
||||
const issueSources: BriefCardSource[] = bundle.issues.map((issue) => {
|
||||
const right = issueRightTag(issue, bundle, treeIssueIds);
|
||||
return {
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "issue",
|
||||
sourceId: issue.id,
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier,
|
||||
titleLine: truncate(issue.title, 160),
|
||||
rightTag: right.tag,
|
||||
linkPath: issueHref(issue),
|
||||
isIntraTreeBlocked: right.intraTreeBlocked,
|
||||
eventAt: toIso(issueEventTime(issue) || Date.now()),
|
||||
metadata: { priority: issue.priority ?? null },
|
||||
};
|
||||
});
|
||||
|
||||
const runSources = [
|
||||
...Object.values(bundle.activeRuns ?? {}).flat(),
|
||||
...(bundle.runs ?? []),
|
||||
].map((run): BriefCardSource => {
|
||||
const issue = sourceIssue(bundle, run.issueId);
|
||||
return {
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "run",
|
||||
sourceId: run.id,
|
||||
issueId: run.issueId,
|
||||
identifier: issue?.identifier ?? null,
|
||||
titleLine: truncate(issue ? `Run for ${issue.title}` : "Heartbeat run", 160),
|
||||
rightTag: ["failed", "error"].includes(run.status) ? "failed" : run.status,
|
||||
linkPath: sourceLink(bundle, "run", run.id, run.issueId),
|
||||
isIntraTreeBlocked: null,
|
||||
eventAt: toIso(runEventTime(run) || Date.now()),
|
||||
metadata: { error: run.error ?? null },
|
||||
};
|
||||
});
|
||||
|
||||
const commentSources = (bundle.comments ?? []).map((comment): BriefCardSource => {
|
||||
const issue = sourceIssue(bundle, comment.issueId);
|
||||
return {
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "comment",
|
||||
sourceId: comment.id,
|
||||
issueId: comment.issueId,
|
||||
identifier: issue?.identifier ?? null,
|
||||
titleLine: truncate(comment.body || `Comment on ${issue?.title ?? "issue"}`, 160),
|
||||
rightTag: "comment",
|
||||
linkPath: sourceLink(bundle, "comment", comment.id, comment.issueId),
|
||||
isIntraTreeBlocked: null,
|
||||
eventAt: toIso(eventTime(comment.createdAt) || Date.now()),
|
||||
metadata: { authorUserId: comment.authorUserId ?? null },
|
||||
};
|
||||
});
|
||||
|
||||
const documentSources = (bundle.documents ?? []).map((document): BriefCardSource => {
|
||||
const issue = sourceIssue(bundle, document.issueId);
|
||||
return {
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "document",
|
||||
sourceId: document.id,
|
||||
issueId: document.issueId,
|
||||
identifier: issue?.identifier ?? null,
|
||||
titleLine: truncate(document.title ?? document.key, 160),
|
||||
rightTag: "document",
|
||||
linkPath: sourceLink(bundle, "document", document.id, document.issueId, document.key),
|
||||
isIntraTreeBlocked: null,
|
||||
eventAt: toIso(eventTime(document.updatedAt) || Date.now()),
|
||||
metadata: { key: document.key },
|
||||
};
|
||||
});
|
||||
|
||||
const interactionSources = (bundle.interactions ?? []).map((interaction): BriefCardSource => {
|
||||
const issue = sourceIssue(bundle, interaction.issueId);
|
||||
return {
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "interaction",
|
||||
sourceId: interaction.id,
|
||||
issueId: interaction.issueId,
|
||||
identifier: issue?.identifier ?? null,
|
||||
titleLine: truncate(interaction.kind.replaceAll("_", " "), 160),
|
||||
rightTag: pendingInteractionForUser(interaction, bundle.userId) ? "asked you" : interaction.status,
|
||||
linkPath: sourceLink(bundle, "interaction", interaction.id, interaction.issueId),
|
||||
isIntraTreeBlocked: null,
|
||||
eventAt: toIso(eventTime(interaction.updatedAt, interaction.createdAt) || Date.now()),
|
||||
metadata: { kind: interaction.kind, targetUserId: interaction.targetUserId ?? null },
|
||||
};
|
||||
});
|
||||
|
||||
const approvalSources = (bundle.approvals ?? []).map((approval): BriefCardSource => {
|
||||
const issue = sourceIssue(bundle, approval.issueId);
|
||||
return {
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "approval",
|
||||
sourceId: approval.id,
|
||||
issueId: approval.issueId,
|
||||
identifier: issue?.identifier ?? null,
|
||||
titleLine: truncate(`Approval for ${issue?.title ?? "issue"}`, 160),
|
||||
rightTag: approval.status === "pending_approval" ? "approval" : approval.status,
|
||||
linkPath: sourceLink(bundle, "approval", approval.id, approval.issueId),
|
||||
isIntraTreeBlocked: null,
|
||||
eventAt: toIso(eventTime(approval.decidedAt, approval.createdAt) || Date.now()),
|
||||
metadata: { reviewerUserId: approval.reviewerUserId ?? null },
|
||||
};
|
||||
});
|
||||
|
||||
const workProductSources = (bundle.workProducts ?? []).map((workProduct): BriefCardSource => {
|
||||
const issue = sourceIssue(bundle, workProduct.issueId);
|
||||
return {
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "work_product",
|
||||
sourceId: workProduct.id,
|
||||
issueId: workProduct.issueId,
|
||||
identifier: issue?.identifier ?? null,
|
||||
titleLine: truncate(workProduct.title, 160),
|
||||
rightTag: workProduct.status ?? "work product",
|
||||
linkPath: sourceLink(bundle, "work_product", workProduct.id, workProduct.issueId),
|
||||
isIntraTreeBlocked: null,
|
||||
eventAt: toIso(eventTime(workProduct.updatedAt, workProduct.createdAt) || Date.now()),
|
||||
metadata: {},
|
||||
};
|
||||
});
|
||||
|
||||
const activitySources = (bundle.activityEvents ?? []).map((activity): BriefCardSource => ({
|
||||
id: idFactory(),
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
sourceKind: "activity_event",
|
||||
sourceId: activity.id,
|
||||
issueId: activity.issueId ?? null,
|
||||
identifier: sourceIssue(bundle, activity.issueId)?.identifier ?? null,
|
||||
titleLine: truncate(activity.action, 160),
|
||||
rightTag: "activity",
|
||||
linkPath: sourceLink(bundle, "activity_event", activity.id, activity.issueId ?? null),
|
||||
isIntraTreeBlocked: null,
|
||||
eventAt: toIso(eventTime(activity.createdAt) || Date.now()),
|
||||
metadata: { action: activity.action },
|
||||
}));
|
||||
|
||||
return [
|
||||
...issueSources,
|
||||
...runSources,
|
||||
...commentSources,
|
||||
...documentSources,
|
||||
...interactionSources,
|
||||
...approvalSources,
|
||||
...workProductSources,
|
||||
...activitySources,
|
||||
].sort((a, b) => sourcePriority(a) - sourcePriority(b) || new Date(b.eventAt).getTime() - new Date(a.eventAt).getTime());
|
||||
}
|
||||
|
||||
function fallbackSummary(state: BriefCardState, rows: BriefTaskRow[]): string {
|
||||
const row = rows[0];
|
||||
if (!row) return "No current source rows are available for this brief.";
|
||||
if (state === "blocked") return `${row.identifier ?? "A source issue"} is blocked and needs out-of-tree progress.`;
|
||||
if (state === "waiting-user") return `${row.identifier ?? "A source item"} is waiting on your response.`;
|
||||
if (state === "waiting-reviewer") return `${row.identifier ?? "A source item"} is in review with a typed reviewer.`;
|
||||
if (state === "live") return `${row.identifier ?? "A source issue"} is active with recent work in progress.`;
|
||||
if (state === "done") return `${row.identifier ?? "The root issue"} was completed recently.`;
|
||||
if (state === "error") return `${row.identifier ?? "A source issue"} has a recovery or repeated run failure signal.`;
|
||||
return `${row.identifier ?? "This area"} has no recent meaningful activity.`;
|
||||
}
|
||||
|
||||
export function buildDeterministicBriefCard(bundle: BriefsSourceBundle, options: DeterministicBriefOptions = {}): BriefCard {
|
||||
assertCompanyScope(bundle);
|
||||
const root = getRootIssue(bundle);
|
||||
const idFactory = options.idFactory ?? randomUUID;
|
||||
const cardId = idFactory();
|
||||
const snapshotId = idFactory();
|
||||
const nowMs = asTime(options.now ?? new Date()) ?? Date.now();
|
||||
const lastEventMs = lastMeaningfulEventMs(bundle) || nowMs;
|
||||
const stateResult = resolveBriefCardState(bundle, options);
|
||||
const summaryStatus = options.summaryStatus ?? "fallback";
|
||||
const groupingDescription = truncate(
|
||||
bundle.groupingDescription ?? `Issue tree rooted at ${root.identifier ?? root.id}: ${root.title}`,
|
||||
500,
|
||||
);
|
||||
const slug = slugifyBriefGrouping(groupingDescription);
|
||||
const sources = buildSources(bundle, cardId, idFactory);
|
||||
const taskRows = sources.map(toTaskRow).filter((row): row is BriefTaskRow => Boolean(row)).slice(0, 3);
|
||||
const staleAt = addMs(lastEventMs, days(options.preferences?.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS));
|
||||
const retentionMs = stateResult.state === "done"
|
||||
? hours(options.preferences?.doneRetentionHours ?? DEFAULT_DONE_RETENTION_HOURS)
|
||||
: days(options.preferences?.retentionDays ?? DEFAULT_RETENTION_DAYS);
|
||||
const pinned = options.pinned ?? false;
|
||||
const summaryParagraph = summaryStatus === "ok"
|
||||
? truncate(options.summaryParagraph ?? fallbackSummary(stateResult.state, taskRows), 260)
|
||||
: null;
|
||||
|
||||
const snapshot: BriefSnapshot = {
|
||||
id: snapshotId,
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
cardId,
|
||||
summaryParagraph,
|
||||
summaryStatus,
|
||||
summaryModel: options.summaryModel ?? null,
|
||||
summaryTokensIn: options.summaryTokensIn ?? null,
|
||||
summaryTokensOut: options.summaryTokensOut ?? null,
|
||||
summaryFailureReason: summaryStatus === "fallback" ? options.summaryFailureReason ?? "model_error" : null,
|
||||
taskRows,
|
||||
evidenceSourceIds: summaryStatus === "ok" ? taskRows.map((row) => row.sourceId) : [],
|
||||
generatedByAgentId: options.generatedByAgentId ?? null,
|
||||
generatedByRunId: options.generatedByRunId ?? null,
|
||||
deterministicStateInputs: stateResult.inputs,
|
||||
createdAt: toIso(nowMs),
|
||||
};
|
||||
|
||||
return {
|
||||
id: cardId,
|
||||
companyId: bundle.companyId,
|
||||
userId: bundle.userId,
|
||||
slug,
|
||||
title: truncate(bundle.title ?? root.title, 90),
|
||||
groupingDescription,
|
||||
rootIssueId: root.id,
|
||||
state: stateResult.state,
|
||||
summaryStatus,
|
||||
pinned,
|
||||
hidden: options.hidden ?? false,
|
||||
staleAt: toIso(staleAt),
|
||||
expiresAt: pinned ? null : toIso(addMs(lastEventMs, retentionMs)),
|
||||
latestSnapshotId: snapshotId,
|
||||
lastMeaningfulEventAt: toIso(lastEventMs),
|
||||
snapshot,
|
||||
sources,
|
||||
moreSourceCount: Math.max(0, sources.length - taskRows.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function isBriefTreeRelevantToUser(bundle: BriefsSourceBundle): boolean {
|
||||
assertCompanyScope(bundle);
|
||||
return bundle.issues.some((issue) => issue.createdByUserId === bundle.userId || issue.updatedByUserId === bundle.userId || issueWaitingOnUser(issue, bundle.userId))
|
||||
|| (bundle.comments ?? []).some((comment) => comment.authorUserId === bundle.userId)
|
||||
|| (bundle.documents ?? []).some((document) => document.createdByUserId === bundle.userId || document.updatedByUserId === bundle.userId)
|
||||
|| (bundle.interactions ?? []).some((interaction) => interaction.targetUserId === bundle.userId)
|
||||
|| (bundle.approvals ?? []).some((approval) => approval.reviewerUserId === bundle.userId || approval.decidedByUserId === bundle.userId)
|
||||
|| bundle.issues.some((issue) => Boolean(issue.assigneeAgentId && bundle.relevantAgentIds?.includes(issue.assigneeAgentId)));
|
||||
}
|
||||
|
||||
export function selectRelevantBriefTrees(input: {
|
||||
companyId: string;
|
||||
userId: string;
|
||||
candidateTrees: BriefsSourceBundle[];
|
||||
now?: Date | string;
|
||||
discoveryWindowDays?: number;
|
||||
}): BriefsSourceBundle[] {
|
||||
const nowMs = asTime(input.now ?? new Date()) ?? Date.now();
|
||||
const windowMs = days(input.discoveryWindowDays ?? DEFAULT_DISCOVERY_WINDOW_DAYS);
|
||||
return input.candidateTrees.filter((tree) => {
|
||||
if (tree.companyId !== input.companyId || tree.userId !== input.userId) return false;
|
||||
assertCompanyScope(tree);
|
||||
return isBriefTreeRelevantToUser(tree) && nowMs - lastMeaningfulEventMs(tree) <= windowMs;
|
||||
});
|
||||
}
|
||||
|
||||
export function sortBriefCards(cards: BriefCard[]): BriefCard[] {
|
||||
return [...cards].sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return new Date(b.lastMeaningfulEventAt).getTime() - new Date(a.lastMeaningfulEventAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export function filterExpiredBriefCards(cards: BriefCard[], now: Date | string = new Date()): BriefCard[] {
|
||||
const nowMs = asTime(now) ?? Date.now();
|
||||
return cards.filter((card) => card.pinned || !card.expiresAt || new Date(card.expiresAt).getTime() > nowMs);
|
||||
}
|
||||
|
||||
export function dedupeBriefCursorEvents(
|
||||
events: BriefCursorEvent[],
|
||||
previousDedupeState: readonly string[] = [],
|
||||
): BriefCursorDedupeResult {
|
||||
const seen = new Set(previousDedupeState);
|
||||
const freshEvents: BriefCursorEvent[] = [];
|
||||
for (const event of events) {
|
||||
const key = event.fingerprint ?? event.id;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
freshEvents.push(event);
|
||||
}
|
||||
const sortedState = [...seen].slice(-MAX_DEDUPE_STATE);
|
||||
const lastSeen = freshEvents.reduce<number | null>((max, event) => {
|
||||
const time = asTime(event.eventAt);
|
||||
return time == null ? max : Math.max(max ?? 0, time);
|
||||
}, null);
|
||||
return {
|
||||
freshEvents,
|
||||
dedupeState: sortedState,
|
||||
lastSeenAt: lastSeen == null ? null : toIso(lastSeen),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default } from "./worker.js";
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export * from "./contracts.js";
|
||||
export * from "./deterministic-card-service.js";
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const PLUGIN_ID = "paperclipai.plugin-briefs";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Briefs",
|
||||
description: "Company-scoped briefing cards backed by deterministic Paperclip work-state analysis.",
|
||||
author: "Paperclip",
|
||||
categories: ["automation", "ui"],
|
||||
capabilities: [
|
||||
"database.namespace.migrate",
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"goals.read",
|
||||
"agents.read",
|
||||
"issues.read",
|
||||
"issue.subtree.read",
|
||||
"issue.relations.read",
|
||||
"issue.comments.read",
|
||||
"issue.documents.read",
|
||||
"issues.orchestration.read",
|
||||
"activity.log.write",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js"
|
||||
},
|
||||
database: {
|
||||
namespaceSlug: "briefs",
|
||||
migrationsDir: "migrations",
|
||||
coreReadTables: ["companies", "issues"]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,332 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PluginDatabaseClient } from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
BriefCard,
|
||||
BriefCardSource,
|
||||
BriefPreferences,
|
||||
BriefSnapshot,
|
||||
} from "./contracts.js";
|
||||
|
||||
type BriefCardRow = {
|
||||
id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
grouping_description: string;
|
||||
root_issue_id: string | null;
|
||||
state: BriefCard["state"];
|
||||
summary_status: BriefCard["summaryStatus"];
|
||||
pinned: boolean;
|
||||
hidden: boolean;
|
||||
stale_at: string;
|
||||
expires_at: string | null;
|
||||
latest_snapshot_id: string | null;
|
||||
last_meaningful_event_at: string;
|
||||
};
|
||||
|
||||
type BriefSnapshotRow = {
|
||||
id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
card_id: string;
|
||||
summary_paragraph: string | null;
|
||||
summary_status: BriefSnapshot["summaryStatus"];
|
||||
summary_model: string | null;
|
||||
summary_tokens_in: number | null;
|
||||
summary_tokens_out: number | null;
|
||||
summary_failure_reason: BriefSnapshot["summaryFailureReason"];
|
||||
task_rows: unknown;
|
||||
evidence_source_ids: unknown;
|
||||
generated_by_agent_id: string | null;
|
||||
generated_by_run_id: string | null;
|
||||
deterministic_state_inputs: unknown;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type BriefSourceRow = {
|
||||
id: string;
|
||||
company_id: string;
|
||||
user_id: string;
|
||||
card_id: string;
|
||||
source_kind: BriefCardSource["sourceKind"];
|
||||
source_id: string;
|
||||
issue_id: string | null;
|
||||
identifier: string | null;
|
||||
title_line: string;
|
||||
right_tag: string;
|
||||
link_path: string;
|
||||
is_intra_tree_blocked: boolean | null;
|
||||
event_at: string;
|
||||
metadata: unknown;
|
||||
};
|
||||
|
||||
function table(namespace: string, name: string): string {
|
||||
return `${namespace}.${name}`;
|
||||
}
|
||||
|
||||
function toSnapshot(row: BriefSnapshotRow): BriefSnapshot {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.company_id,
|
||||
userId: row.user_id,
|
||||
cardId: row.card_id,
|
||||
summaryParagraph: row.summary_paragraph,
|
||||
summaryStatus: row.summary_status,
|
||||
summaryModel: row.summary_model,
|
||||
summaryTokensIn: row.summary_tokens_in,
|
||||
summaryTokensOut: row.summary_tokens_out,
|
||||
summaryFailureReason: row.summary_failure_reason,
|
||||
taskRows: Array.isArray(row.task_rows) ? row.task_rows as BriefSnapshot["taskRows"] : [],
|
||||
evidenceSourceIds: Array.isArray(row.evidence_source_ids) ? row.evidence_source_ids as string[] : [],
|
||||
generatedByAgentId: row.generated_by_agent_id,
|
||||
generatedByRunId: row.generated_by_run_id,
|
||||
deterministicStateInputs: row.deterministic_state_inputs && typeof row.deterministic_state_inputs === "object"
|
||||
? row.deterministic_state_inputs as Record<string, unknown>
|
||||
: {},
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toSource(row: BriefSourceRow): BriefCardSource {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.company_id,
|
||||
userId: row.user_id,
|
||||
cardId: row.card_id,
|
||||
sourceKind: row.source_kind,
|
||||
sourceId: row.source_id,
|
||||
issueId: row.issue_id,
|
||||
identifier: row.identifier,
|
||||
titleLine: row.title_line,
|
||||
rightTag: row.right_tag,
|
||||
linkPath: row.link_path,
|
||||
isIntraTreeBlocked: row.is_intra_tree_blocked,
|
||||
eventAt: row.event_at,
|
||||
metadata: row.metadata && typeof row.metadata === "object" ? row.metadata as Record<string, unknown> : {},
|
||||
};
|
||||
}
|
||||
|
||||
function toCard(row: BriefCardRow, snapshot: BriefSnapshot, sources: BriefCardSource[]): BriefCard {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.company_id,
|
||||
userId: row.user_id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
groupingDescription: row.grouping_description,
|
||||
rootIssueId: row.root_issue_id,
|
||||
state: row.state,
|
||||
summaryStatus: row.summary_status,
|
||||
pinned: row.pinned,
|
||||
hidden: row.hidden,
|
||||
staleAt: row.stale_at,
|
||||
expiresAt: row.expires_at,
|
||||
latestSnapshotId: row.latest_snapshot_id,
|
||||
lastMeaningfulEventAt: row.last_meaningful_event_at,
|
||||
snapshot,
|
||||
sources,
|
||||
moreSourceCount: Math.max(0, sources.length - snapshot.taskRows.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBriefsStore(db: PluginDatabaseClient) {
|
||||
const cardsTable = table(db.namespace, "briefs_cards");
|
||||
const sourcesTable = table(db.namespace, "briefs_card_sources");
|
||||
const snapshotsTable = table(db.namespace, "briefs_card_snapshots");
|
||||
const preferencesTable = table(db.namespace, "briefs_user_preferences");
|
||||
|
||||
return {
|
||||
async listCards(input: { companyId: string; userId: string; includeHidden?: boolean; limit?: number }): Promise<BriefCard[]> {
|
||||
const cardRows = await db.query<BriefCardRow>(
|
||||
`SELECT id, company_id, user_id, slug, title, grouping_description, root_issue_id, state, summary_status,
|
||||
pinned, hidden, stale_at, expires_at, latest_snapshot_id, last_meaningful_event_at
|
||||
FROM ${cardsTable}
|
||||
WHERE company_id = $1 AND user_id = $2 AND ($3::boolean OR hidden = false)
|
||||
ORDER BY pinned DESC, last_meaningful_event_at DESC, id DESC
|
||||
LIMIT $4`,
|
||||
[input.companyId, input.userId, Boolean(input.includeHidden), input.limit ?? 50],
|
||||
);
|
||||
|
||||
const cards: BriefCard[] = [];
|
||||
for (const card of cardRows) {
|
||||
if (!card.latest_snapshot_id) continue;
|
||||
const snapshots = await db.query<BriefSnapshotRow>(
|
||||
`SELECT id, company_id, user_id, card_id, summary_paragraph, summary_status, summary_model,
|
||||
summary_tokens_in, summary_tokens_out, summary_failure_reason, task_rows, evidence_source_ids,
|
||||
generated_by_agent_id, generated_by_run_id, deterministic_state_inputs, created_at
|
||||
FROM ${snapshotsTable}
|
||||
WHERE company_id = $1 AND user_id = $2 AND card_id = $3 AND id = $4
|
||||
LIMIT 1`,
|
||||
[input.companyId, input.userId, card.id, card.latest_snapshot_id],
|
||||
);
|
||||
const snapshot = snapshots[0];
|
||||
if (!snapshot) continue;
|
||||
const sources = await db.query<BriefSourceRow>(
|
||||
`SELECT id, company_id, user_id, card_id, source_kind, source_id, issue_id, identifier, title_line,
|
||||
right_tag, link_path, is_intra_tree_blocked, event_at, metadata
|
||||
FROM ${sourcesTable}
|
||||
WHERE company_id = $1 AND user_id = $2 AND card_id = $3
|
||||
ORDER BY event_at DESC, id DESC`,
|
||||
[input.companyId, input.userId, card.id],
|
||||
);
|
||||
cards.push(toCard(card, toSnapshot(snapshot), sources.map(toSource)));
|
||||
}
|
||||
return cards;
|
||||
},
|
||||
|
||||
async saveCard(card: BriefCard): Promise<BriefCard> {
|
||||
await db.execute(
|
||||
`INSERT INTO ${cardsTable} (
|
||||
id, company_id, user_id, slug, title, grouping_description, root_issue_id, state, summary_status,
|
||||
pinned, hidden, stale_at, expires_at, latest_snapshot_id, last_meaningful_event_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz, $13::timestamptz, NULL, $14::timestamptz)
|
||||
ON CONFLICT (company_id, user_id, slug) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
grouping_description = EXCLUDED.grouping_description,
|
||||
root_issue_id = EXCLUDED.root_issue_id,
|
||||
state = EXCLUDED.state,
|
||||
summary_status = EXCLUDED.summary_status,
|
||||
pinned = ${cardsTable}.pinned OR EXCLUDED.pinned,
|
||||
hidden = EXCLUDED.hidden,
|
||||
stale_at = EXCLUDED.stale_at,
|
||||
expires_at = CASE WHEN ${cardsTable}.pinned OR EXCLUDED.pinned THEN NULL ELSE EXCLUDED.expires_at END,
|
||||
last_meaningful_event_at = EXCLUDED.last_meaningful_event_at,
|
||||
updated_at = now()`,
|
||||
[
|
||||
card.id,
|
||||
card.companyId,
|
||||
card.userId,
|
||||
card.slug,
|
||||
card.title,
|
||||
card.groupingDescription,
|
||||
card.rootIssueId,
|
||||
card.state,
|
||||
card.summaryStatus,
|
||||
card.pinned,
|
||||
card.hidden,
|
||||
card.staleAt,
|
||||
card.expiresAt,
|
||||
card.lastMeaningfulEventAt,
|
||||
],
|
||||
);
|
||||
|
||||
const existingRows = await db.query<{ id: string; pinned: boolean }>(
|
||||
`SELECT id, pinned FROM ${cardsTable} WHERE company_id = $1 AND user_id = $2 AND slug = $3 LIMIT 1`,
|
||||
[card.companyId, card.userId, card.slug],
|
||||
);
|
||||
const persistedCardId = existingRows[0]?.id ?? card.id;
|
||||
|
||||
await db.execute(
|
||||
`DELETE FROM ${sourcesTable} WHERE company_id = $1 AND user_id = $2 AND card_id = $3`,
|
||||
[card.companyId, card.userId, persistedCardId],
|
||||
);
|
||||
for (const source of card.sources) {
|
||||
await db.execute(
|
||||
`INSERT INTO ${sourcesTable} (
|
||||
id, company_id, user_id, card_id, source_kind, source_id, issue_id, identifier, title_line,
|
||||
right_tag, link_path, is_intra_tree_blocked, event_at, metadata
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz, $14::jsonb)`,
|
||||
[
|
||||
source.id,
|
||||
source.companyId,
|
||||
source.userId,
|
||||
persistedCardId,
|
||||
source.sourceKind,
|
||||
source.sourceId,
|
||||
source.issueId,
|
||||
source.identifier,
|
||||
source.titleLine,
|
||||
source.rightTag,
|
||||
source.linkPath,
|
||||
source.isIntraTreeBlocked,
|
||||
source.eventAt,
|
||||
JSON.stringify(source.metadata ?? {}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
`INSERT INTO ${snapshotsTable} (
|
||||
id, company_id, user_id, card_id, summary_paragraph, summary_status, summary_model,
|
||||
summary_tokens_in, summary_tokens_out, summary_failure_reason, task_rows, evidence_source_ids,
|
||||
generated_by_agent_id, generated_by_run_id, deterministic_state_inputs
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12::jsonb, $13, $14, $15::jsonb)`,
|
||||
[
|
||||
card.snapshot.id,
|
||||
card.companyId,
|
||||
card.userId,
|
||||
persistedCardId,
|
||||
card.snapshot.summaryParagraph,
|
||||
card.snapshot.summaryStatus,
|
||||
card.snapshot.summaryModel,
|
||||
card.snapshot.summaryTokensIn,
|
||||
card.snapshot.summaryTokensOut,
|
||||
card.snapshot.summaryFailureReason,
|
||||
JSON.stringify(card.snapshot.taskRows),
|
||||
JSON.stringify(card.snapshot.evidenceSourceIds),
|
||||
card.snapshot.generatedByAgentId,
|
||||
card.snapshot.generatedByRunId,
|
||||
JSON.stringify(card.snapshot.deterministicStateInputs),
|
||||
],
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
`UPDATE ${cardsTable}
|
||||
SET latest_snapshot_id = $1, updated_at = now()
|
||||
WHERE company_id = $2 AND user_id = $3 AND id = $4`,
|
||||
[card.snapshot.id, card.companyId, card.userId, persistedCardId],
|
||||
);
|
||||
|
||||
return {
|
||||
...card,
|
||||
id: persistedCardId,
|
||||
pinned: existingRows[0]?.pinned ?? card.pinned,
|
||||
latestSnapshotId: card.snapshot.id,
|
||||
snapshot: { ...card.snapshot, cardId: persistedCardId },
|
||||
sources: card.sources.map((source) => ({ ...source, cardId: persistedCardId })),
|
||||
};
|
||||
},
|
||||
|
||||
async setPinned(input: { companyId: string; userId: string; cardId: string; pinned: boolean }): Promise<void> {
|
||||
await db.execute(
|
||||
`UPDATE ${cardsTable}
|
||||
SET pinned = $1, expires_at = CASE WHEN $1 THEN NULL ELSE expires_at END, updated_at = now()
|
||||
WHERE company_id = $2 AND user_id = $3 AND id = $4`,
|
||||
[input.pinned, input.companyId, input.userId, input.cardId],
|
||||
);
|
||||
},
|
||||
|
||||
async upsertPreferences(preferences: BriefPreferences): Promise<void> {
|
||||
await db.execute(
|
||||
`INSERT INTO ${preferencesTable} (
|
||||
id, company_id, user_id, cadence, retention_days, done_retention_hours, stale_after_days,
|
||||
max_unpinned_cards, scope
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (company_id, user_id) DO UPDATE SET
|
||||
cadence = EXCLUDED.cadence,
|
||||
retention_days = EXCLUDED.retention_days,
|
||||
done_retention_hours = EXCLUDED.done_retention_hours,
|
||||
stale_after_days = EXCLUDED.stale_after_days,
|
||||
max_unpinned_cards = EXCLUDED.max_unpinned_cards,
|
||||
scope = EXCLUDED.scope,
|
||||
updated_at = now()`,
|
||||
[
|
||||
randomUUID(),
|
||||
preferences.companyId,
|
||||
preferences.userId,
|
||||
preferences.cadence,
|
||||
preferences.retentionDays,
|
||||
preferences.doneRetentionHours,
|
||||
preferences.staleAfterDays,
|
||||
preferences.maxUnpinnedCards,
|
||||
preferences.scope,
|
||||
],
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
briefPreferencesSchema,
|
||||
listBriefCardsInputSchema,
|
||||
pinBriefCardInputSchema,
|
||||
updateBriefPreferencesInputSchema,
|
||||
} from "./contracts.js";
|
||||
import {
|
||||
buildDeterministicBriefCard,
|
||||
type BriefsSourceBundle,
|
||||
type DeterministicBriefOptions,
|
||||
} from "./deterministic-card-service.js";
|
||||
import { createBriefsStore } from "./store.js";
|
||||
|
||||
function objectParam(value: unknown, name: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`${name} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
const store = createBriefsStore(ctx.db);
|
||||
|
||||
ctx.data.register("cards", async (params) => {
|
||||
const input = listBriefCardsInputSchema.parse(params);
|
||||
return {
|
||||
cards: await store.listCards(input),
|
||||
};
|
||||
});
|
||||
|
||||
ctx.actions.register("save-deterministic-card", async (params) => {
|
||||
const input = objectParam(params, "params");
|
||||
const bundle = objectParam(input.bundle, "bundle") as BriefsSourceBundle;
|
||||
const options = (input.options && typeof input.options === "object" ? input.options : {}) as DeterministicBriefOptions;
|
||||
const card = await store.saveCard(buildDeterministicBriefCard(bundle, options));
|
||||
await ctx.activity.log({
|
||||
companyId: card.companyId,
|
||||
message: `Updated briefing card "${card.title}"`,
|
||||
entityType: "plugin:briefs:card",
|
||||
entityId: card.id,
|
||||
metadata: {
|
||||
userId: card.userId,
|
||||
state: card.state,
|
||||
rootIssueId: card.rootIssueId,
|
||||
summaryStatus: card.summaryStatus,
|
||||
},
|
||||
});
|
||||
return { card };
|
||||
});
|
||||
|
||||
ctx.actions.register("pin-card", async (params) => {
|
||||
const input = pinBriefCardInputSchema.parse(params);
|
||||
await store.setPinned(input);
|
||||
await ctx.activity.log({
|
||||
companyId: input.companyId,
|
||||
message: `${input.pinned ? "Pinned" : "Unpinned"} briefing card`,
|
||||
entityType: "plugin:briefs:card",
|
||||
entityId: input.cardId,
|
||||
metadata: { userId: input.userId, pinned: input.pinned },
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ctx.actions.register("update-preferences", async (params) => {
|
||||
const input = updateBriefPreferencesInputSchema.parse(params);
|
||||
const preferences = briefPreferencesSchema.parse(input);
|
||||
await store.upsertPreferences(preferences);
|
||||
await ctx.activity.log({
|
||||
companyId: preferences.companyId,
|
||||
message: "Updated briefing preferences",
|
||||
entityType: "plugin:briefs:preferences",
|
||||
entityId: preferences.userId,
|
||||
metadata: preferences,
|
||||
});
|
||||
return { preferences };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Briefs deterministic card service ready" };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDeterministicBriefCard,
|
||||
dedupeBriefCursorEvents,
|
||||
filterExpiredBriefCards,
|
||||
isBriefTreeRelevantToUser,
|
||||
resolveBriefCardState,
|
||||
selectRelevantBriefTrees,
|
||||
sortBriefCards,
|
||||
type BriefsIssueInput,
|
||||
type BriefsSourceBundle,
|
||||
} from "../src/deterministic-card-service.js";
|
||||
|
||||
const companyId = "company-1";
|
||||
const userId = "user-1";
|
||||
const now = "2026-05-22T12:00:00.000Z";
|
||||
|
||||
function ids() {
|
||||
let counter = 0;
|
||||
return () => `00000000-0000-4000-8000-${String(++counter).padStart(12, "0")}`;
|
||||
}
|
||||
|
||||
function issue(overrides: Partial<BriefsIssueInput> = {}): BriefsIssueInput {
|
||||
return {
|
||||
id: "issue-root",
|
||||
companyId,
|
||||
parentId: null,
|
||||
title: "Briefs plugin planning",
|
||||
identifier: "PAP-2381",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: userId,
|
||||
updatedAt: "2026-05-22T11:30:00.000Z",
|
||||
createdAt: "2026-05-20T11:30:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function bundle(overrides: Partial<BriefsSourceBundle> = {}): BriefsSourceBundle {
|
||||
return {
|
||||
companyId,
|
||||
userId,
|
||||
rootIssueId: "issue-root",
|
||||
issues: [issue()],
|
||||
relations: {},
|
||||
activeRuns: {},
|
||||
comments: [],
|
||||
documents: [],
|
||||
interactions: [],
|
||||
approvals: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deterministic Briefs cards", () => {
|
||||
it("rejects cross-company source data before producing a card", () => {
|
||||
expect(() => buildDeterministicBriefCard(bundle({
|
||||
issues: [issue({ companyId: "company-2" })],
|
||||
}), { now, idFactory: ids() })).toThrow(/another company/);
|
||||
});
|
||||
|
||||
it("uses user involvement and the discovery window for source selection", () => {
|
||||
const relevant = bundle({
|
||||
comments: [{
|
||||
id: "comment-1",
|
||||
companyId,
|
||||
issueId: "issue-root",
|
||||
authorUserId: userId,
|
||||
body: "Please continue.",
|
||||
createdAt: "2026-05-22T10:00:00.000Z",
|
||||
}],
|
||||
});
|
||||
const irrelevant = bundle({
|
||||
rootIssueId: "other-root",
|
||||
issues: [issue({
|
||||
id: "other-root",
|
||||
identifier: "PAP-10",
|
||||
createdByUserId: "someone-else",
|
||||
updatedAt: "2026-05-22T10:00:00.000Z",
|
||||
})],
|
||||
});
|
||||
const stale = bundle({
|
||||
rootIssueId: "stale-root",
|
||||
issues: [issue({
|
||||
id: "stale-root",
|
||||
createdByUserId: userId,
|
||||
createdAt: "2026-04-01T09:00:00.000Z",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
})],
|
||||
});
|
||||
|
||||
expect(isBriefTreeRelevantToUser(relevant)).toBe(true);
|
||||
expect(selectRelevantBriefTrees({
|
||||
companyId,
|
||||
userId,
|
||||
candidateTrees: [relevant, irrelevant, stale],
|
||||
now,
|
||||
})).toEqual([relevant]);
|
||||
});
|
||||
|
||||
it("does not promote intra-tree sequencing blockers to card-level blocked", () => {
|
||||
const child = issue({
|
||||
id: "issue-child",
|
||||
parentId: "issue-root",
|
||||
identifier: "PAP-9961",
|
||||
title: "Data model",
|
||||
status: "blocked",
|
||||
updatedAt: "2026-05-22T11:45:00.000Z",
|
||||
});
|
||||
|
||||
const card = buildDeterministicBriefCard(bundle({
|
||||
issues: [issue(), child],
|
||||
relations: {
|
||||
"issue-child": {
|
||||
blockedBy: [{ id: "issue-root", companyId, status: "in_progress", identifier: "PAP-2381" }],
|
||||
},
|
||||
},
|
||||
}), { now, idFactory: ids() });
|
||||
|
||||
expect(card.state).toBe("live");
|
||||
expect(card.sources.find((source) => source.sourceId === "issue-child")).toMatchObject({
|
||||
rightTag: "blocked",
|
||||
isIntraTreeBlocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives out-of-tree blockers precedence over waiting and live signals", () => {
|
||||
const blocked = issue({
|
||||
id: "issue-child",
|
||||
parentId: "issue-root",
|
||||
status: "blocked",
|
||||
assigneeUserId: userId,
|
||||
updatedAt: "2026-05-22T11:45:00.000Z",
|
||||
});
|
||||
|
||||
expect(resolveBriefCardState(bundle({
|
||||
issues: [issue(), blocked],
|
||||
relations: {
|
||||
"issue-child": {
|
||||
blockedBy: [{ id: "external-blocker", companyId, status: "todo", identifier: "PAP-9999" }],
|
||||
},
|
||||
},
|
||||
activeRuns: {
|
||||
"issue-root": [{
|
||||
id: "run-1",
|
||||
companyId,
|
||||
issueId: "issue-root",
|
||||
status: "running",
|
||||
startedAt: "2026-05-22T11:50:00.000Z",
|
||||
}],
|
||||
},
|
||||
}), { now }).state).toBe("blocked");
|
||||
});
|
||||
|
||||
it("keeps summary failure orthogonal to deterministic state", () => {
|
||||
const card = buildDeterministicBriefCard(bundle(), {
|
||||
now,
|
||||
summaryStatus: "fallback",
|
||||
summaryFailureReason: "budget_capped",
|
||||
idFactory: ids(),
|
||||
});
|
||||
|
||||
expect(card.state).toBe("live");
|
||||
expect(card.summaryStatus).toBe("fallback");
|
||||
expect(card.snapshot.summaryFailureReason).toBe("budget_capped");
|
||||
});
|
||||
|
||||
it("keeps pinned cards stable while unpinned cards expire predictably", () => {
|
||||
const unpinned = buildDeterministicBriefCard(bundle(), {
|
||||
now,
|
||||
idFactory: ids(),
|
||||
preferences: { retentionDays: 1 },
|
||||
});
|
||||
const pinned = buildDeterministicBriefCard(bundle(), {
|
||||
now,
|
||||
pinned: true,
|
||||
idFactory: ids(),
|
||||
preferences: { retentionDays: 1 },
|
||||
});
|
||||
|
||||
expect(unpinned.expiresAt).toBe("2026-05-23T11:30:00.000Z");
|
||||
expect(pinned.expiresAt).toBeNull();
|
||||
expect(filterExpiredBriefCards([unpinned, pinned], "2026-05-24T00:00:00.000Z")).toEqual([pinned]);
|
||||
});
|
||||
|
||||
it("sorts pinned cards first and then by recent meaningful activity", () => {
|
||||
const olderPinned = buildDeterministicBriefCard(bundle({
|
||||
issues: [issue({ id: "old", updatedAt: "2026-05-22T09:00:00.000Z" })],
|
||||
rootIssueId: "old",
|
||||
}), { now, pinned: true, idFactory: ids() });
|
||||
const newerUnpinned = buildDeterministicBriefCard(bundle({
|
||||
issues: [issue({ id: "new", updatedAt: "2026-05-22T11:00:00.000Z" })],
|
||||
rootIssueId: "new",
|
||||
}), { now, idFactory: ids() });
|
||||
|
||||
expect(sortBriefCards([newerUnpinned, olderPinned]).map((card) => card.id)).toEqual([
|
||||
olderPinned.id,
|
||||
newerUnpinned.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates cursor overlap events by fingerprint", () => {
|
||||
expect(dedupeBriefCursorEvents([
|
||||
{ id: "a", fingerprint: "issue-1:update", eventAt: "2026-05-22T10:00:00.000Z" },
|
||||
{ id: "b", fingerprint: "issue-2:update", eventAt: "2026-05-22T11:00:00.000Z" },
|
||||
{ id: "c", fingerprint: "issue-2:update", eventAt: "2026-05-22T11:05:00.000Z" },
|
||||
], ["issue-1:update"])).toEqual({
|
||||
freshEvents: [
|
||||
{ id: "b", fingerprint: "issue-2:update", eventAt: "2026-05-22T11:00:00.000Z" },
|
||||
],
|
||||
dedupeState: ["issue-1:update", "issue-2:update"],
|
||||
lastSeenAt: "2026-05-22T11:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user