mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
feat(inbox): route requests to people and record personal outcomes (#20959)
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - The inbox collects requests that need a human decision. > - Teams need to know who owns each request and who may act on it. > - A shared close operation must differ from hiding a card for one person. > - This pull request adds personal routing and durable outcomes to the existing attention service. > - People can claim or transfer work and inspect what another person did for them. > - Domain permissions still decide who may resolve each request. ## Linked Issues or Issue Description **Subsystem affected** Server, shared contracts, and database. This is the routing foundation for a stacked inbox UI change. **Problem or motivation** A company-wide attention feed does not give teams a clear personal queue. Archive, withdrawal, transfer, and decisions by another person need distinct scopes and records. **Proposed solution** Resolve a request audience from its addressee, task participants, and configured owners. Provide personal, unassigned, company, all-assignee, and archived views. Add claim, transfer, personal archive and snooze, shared withdrawal, receipts, and personal history. **Alternatives considered** A single shared inbox leaves ownership and personal hiding ambiguous. The service keeps one canonical request and projects its audience instead of copying requests for each person. **Roadmap alignment** This extends the existing Multiple Human Users and Agent Reviews and Approvals capabilities in ROADMAP.md. **Additional context** Refs #19363 and #19520 for the current inbox navigation and confirmation replacement rules. Refs #20268 for related actionable-request work. That PR filters actionability; this stack adds audience routing and personal outcomes. This is part 1 of a two-PR stack. The inbox UI PR #20961 targets this branch. ## What Changed - Add nullable company and project decision owners and a company budget owner. - Add audience views and personal badge counts to the canonical attention projections. - Add claim, transfer, withdrawal, personal archive, snooze, and restore operations with permission checks and activity records. Lock addressed transfers to the current addressee or routing administrators, and validate the recipient against canonical resolution policies. - Add receipts and scoped history for work handled by another person. - Keep closed-task requests and replaced confirmations out of current cards. - Add an idempotent migration and a concurrent receipt-history index. - Update typed fixtures so the existing UI remains compatible with the new contracts. ## Verification - Server layered typechecking passes, including all three test-tree partitions. - Seven routing and interaction-resolution suites pass all 249 tests against a dedicated external PostgreSQL test database on `4caecc21d8`. Six new regressions failed before the repair; they cover takeover followed by approval, legitimate delegation, excluded creators/review requesters, and unauthorized claims. - Prior focused verification covers private approval history and receipts, transfer visibility, audience escalation, withdrawal governance, hidden-card counts, per-recipient confirmation replacement, and SDK source contracts. - A synthetic three-person company passed 37 routing and lifecycle acceptance checks. The UI stack adds browser acceptance for the shared controls. - The complete [GitHub Actions CI matrix](https://github.com/paperclipai/paperclip/actions/runs/43134997331) passes on `4caecc21d891677323a7335f23b1a015cce24c13` (Linux/Node 24: typecheck, build, general and serialized tests, Runner support, browser shards, and aggregate gates). Greptile is 5/5 on that exact head with no open findings. The fresh security scan passes after explicitly rechecking the addressee-transfer repair. - `pnpm verify` reports exit 2 because a CI-global script changed; full CI supplies that broader coverage. Embedded PostgreSQL could not start on this Mac because shared-memory slots were exhausted, so local database checks use a separate external test database. Linux CI covers the embedded setup. ## Risks - Personal badge counts change to the viewer's assigned requests. Accessible feed totals and task membership keep their existing scope. - The migration adds nullable owner fields and a dismissal fingerprint. Its index runs outside a transaction. The migration is classified and rerunnable. - Routing changes the addressee used by resolution authorization. The current addressee or a routing administrator must authorize an addressed transfer; the recipient must satisfy writer access, canonical resolver exclusions, and task review policy under row locks. - The shared validator export barrel crosses 1,000 lines because it exports the new attention schemas. Schema implementation stays in the dedicated attention validator module. ## Model Used OpenAI GPT-6 through Codex. Used repository tools, code execution, and browser checks. This session does not expose a more specific runtime model ID or its context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (OpenAPI contracts and docs/api/attention.md) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
87 files changed
+9600
-332
No files matched your search
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* scripts/count-needs-you-views.ts
|
||||
*
|
||||
* PAP-749 task 1a deliverable: "count how many local cards go into each view".
|
||||
*
|
||||
* It spins up a throwaway, migrated embedded Postgres, runs the Needs You demo
|
||||
* seed (scripts/seed-needs-you-demo.ts) into it, then runs the attention feed
|
||||
* for each demo user under every view= value and prints the card counts. The
|
||||
* demo company is the canonical "local" card set for the integration branch's
|
||||
* preview, so this quantifies the audience migration impact before task 1b
|
||||
* flips the default view in the UI.
|
||||
*
|
||||
* Run from the repo root (resolves server deps via the server package):
|
||||
* pnpm --filter @paperclipai/server exec tsx ../scripts/count-needs-you-views.ts
|
||||
*
|
||||
* Read-condition note: the feed is listed with no actor, i.e. full board read
|
||||
* visibility, which matches the board operator the Needs You page runs as.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createDb,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
authUsers,
|
||||
companies as companiesTable,
|
||||
} from "../packages/db/src/index.js";
|
||||
import type { AttentionFeedView } from "../packages/shared/src/index.js";
|
||||
import { attentionService } from "../server/src/services/attention.js";
|
||||
import { attentionReceiptService } from "../server/src/services/attention-receipts.js";
|
||||
import { decisionHistoryService } from "../server/src/services/decision-history.js";
|
||||
import { issueService } from "../server/src/services/issues.js";
|
||||
import { expirePendingInteractionsOnClosedIssues } from "../server/src/services/closed-issue-interaction-sweep.js";
|
||||
|
||||
// Repo-root scripts can't resolve server-only bare deps directly, so pull
|
||||
// drizzle's helpers through the server package (see the seed script).
|
||||
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
|
||||
const { eq, inArray } = serverRequire("drizzle-orm") as typeof import("drizzle-orm");
|
||||
|
||||
const DEMO_COMPANY_NAME = "Needs You Demo";
|
||||
const DEMO_EMAILS: Record<string, string> = {
|
||||
Scott: "scott@needs-you-demo.test",
|
||||
Dotta: "dotta@needs-you-demo.test",
|
||||
Riley: "riley@needs-you-demo.test",
|
||||
};
|
||||
const VIEWS: AttentionFeedView[] = ["all", "mine", "unassigned", "company"];
|
||||
|
||||
async function main() {
|
||||
const tempDb = await startEmbeddedPostgresTestDatabase("needs-you-counts-");
|
||||
try {
|
||||
const seedScript = fileURLToPath(new URL("./seed-needs-you-demo.ts", import.meta.url));
|
||||
const tsxBin = fileURLToPath(new URL("../server/node_modules/tsx/dist/cli.mjs", import.meta.url));
|
||||
const seedRun = spawnSync(process.execPath, [tsxBin, seedScript], {
|
||||
env: { ...process.env, DATABASE_URL: tempDb.connectionString },
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (seedRun.status !== 0) throw new Error(`seed exited with status ${seedRun.status}`);
|
||||
|
||||
const db = createDb(tempDb.connectionString);
|
||||
const [company] = await db
|
||||
.select({ id: companiesTable.id, prefix: companiesTable.issuePrefix })
|
||||
.from(companiesTable)
|
||||
.where(eq(companiesTable.name, DEMO_COMPANY_NAME));
|
||||
if (!company) throw new Error("demo company not found after seed");
|
||||
|
||||
const userRows = await db
|
||||
.select({ id: authUsers.id, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.email, Object.values(DEMO_EMAILS)));
|
||||
const idByEmail = new Map(userRows.map((row) => [row.email, row.id]));
|
||||
|
||||
const svc = attentionService(db);
|
||||
// Task 2a: stale cards by reason, for Scott's full feed.
|
||||
const printStale = async (label: string) => {
|
||||
const scott = idByEmail.get(DEMO_EMAILS.Scott);
|
||||
if (!scott) return;
|
||||
const feed = await svc.list(company.id, { userId: scott, view: "all" });
|
||||
const stale = feed.items.filter((item) => item.stale);
|
||||
console.log(`\n[${label}] Scott view=all: ${feed.totalCount} card(s), ${stale.length} stale`);
|
||||
for (const item of stale) {
|
||||
console.log(` - ${item.stale!.reason.padEnd(17)} ${item.audience.kind.padEnd(7)} ${item.sourceKind}: ${item.subject.title}`);
|
||||
}
|
||||
};
|
||||
await printStale("before startup sweep");
|
||||
// Task 2a Rule 9: which of Scott's personally hidden cards stay hidden.
|
||||
const scottForHidden = idByEmail.get(DEMO_EMAILS.Scott);
|
||||
if (scottForHidden) {
|
||||
const withHidden = await svc.list(company.id, { userId: scottForHidden, view: "all", includeDismissed: true });
|
||||
console.log("\n[hidden] Scott's archived/snoozed cards:");
|
||||
for (const item of withHidden.items.filter((entry) => entry.dismissal)) {
|
||||
console.log(` - ${item.dismissal!.isActive ? "hidden " : "returned"} ${item.dismissal!.kind.padEnd(7)} ${item.subject.title}`);
|
||||
}
|
||||
}
|
||||
// A server start runs the closed-task sweep once; run it twice to show it is idempotent.
|
||||
console.log(`\n[sweep] first run: ${JSON.stringify(await expirePendingInteractionsOnClosedIssues(db))}`);
|
||||
console.log(`[sweep] second run: ${JSON.stringify(await expirePendingInteractionsOnClosedIssues(db))}`);
|
||||
await printStale("after startup sweep");
|
||||
|
||||
console.log(`\n=== Needs You demo card counts by view (company ${company.id}, prefix ${company.prefix}) ===`);
|
||||
// `badge` is the shared For you badge (task 1b). The columns count cards;
|
||||
// the badge counts obligations, so it is `mine` minus folded evidence (task 2d).
|
||||
console.log("user | all | mine | unassigned | company | badge");
|
||||
console.log("-------+-----+------+------------+---------+------");
|
||||
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
|
||||
const userId = idByEmail.get(email);
|
||||
if (!userId) {
|
||||
console.log(`${name.padEnd(6)} | (user not found)`);
|
||||
continue;
|
||||
}
|
||||
const counts: Record<string, number> = {};
|
||||
let badge = 0;
|
||||
for (const view of VIEWS) {
|
||||
const feed = await svc.list(company.id, { userId, view });
|
||||
counts[view] = feed.totalCount;
|
||||
badge = feed.deskBadgeCount;
|
||||
}
|
||||
console.log(
|
||||
`${name.padEnd(6)} | ${String(counts.all).padStart(3)} | ${String(counts.mine).padStart(4)} | ` +
|
||||
`${String(counts.unassigned).padStart(10)} | ${String(counts.company).padStart(7)} | ${String(badge).padStart(5)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Task 2c: the collapsed Stale (N) group in each view, and what "Withdraw
|
||||
// all stale" would close: cards whose closeModes include withdraw and that
|
||||
// are not required gates (required approvals and governed requests stay).
|
||||
console.log("\n=== Stale group per view (task 2c) ===");
|
||||
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
|
||||
const userId = idByEmail.get(email);
|
||||
if (!userId) continue;
|
||||
for (const view of ["mine", "unassigned", "company"] as const) {
|
||||
const feed = await svc.list(company.id, { userId, view });
|
||||
const stale = feed.items.filter((item) => item.stale);
|
||||
if (!stale.length) continue;
|
||||
const bulk = (item: (typeof stale)[number]) => item.closeModes.includes("withdraw") && !item.required;
|
||||
console.log(`${name} view=${view}: Stale (${stale.length}), Withdraw all stale closes ${stale.filter(bulk).length}`);
|
||||
for (const item of stale) {
|
||||
const verb = bulk(item) ? "withdraw" : item.required ? "required" : "stays ";
|
||||
console.log(` - ${verb} ${item.stale!.reason.padEnd(17)} ${item.sourceKind}: ${item.subject.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Receipts at the top of For you, and tasks unread in Mine (task 2f).
|
||||
console.log("\n=== Receipts (task 2f) ===");
|
||||
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
|
||||
const userId = idByEmail.get(email);
|
||||
if (!userId) continue;
|
||||
const actor = { type: "board", source: "session", userId, companyIds: [company.id], isInstanceAdmin: true } as never;
|
||||
const receipts = await attentionReceiptService(db).list(company.id, { userId, actor });
|
||||
const unread = await issueService(db).list(company.id, { unreadForUserId: userId });
|
||||
console.log(`${name}: ${receipts.length} receipt(s); unread in Mine: ${unread.map((issue) => issue.title).join(", ") || "none"}`);
|
||||
for (const receipt of receipts) {
|
||||
console.log(` - ${receipt.outcome.padEnd(9)} by ${receipt.actorUserId === idByEmail.get(DEMO_EMAILS.Scott) ? "Scott" : receipt.actorUserId}: ${receipt.title}${receipt.reason ? ` (reason: ${receipt.reason})` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Task 2e: each person's Archived view (archived first, then the Snoozed
|
||||
// group) and its tab count, which is never in the badge; then the Decision
|
||||
// history outcome filters, approvals included.
|
||||
console.log("\n=== Archived view (task 2e) ===");
|
||||
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
|
||||
const userId = idByEmail.get(email);
|
||||
if (!userId) continue;
|
||||
const feed = await svc.list(company.id, { userId, view: "archived" });
|
||||
console.log(`${name}: Archived tab ${feed.viewCounts.archived}, badge ${feed.deskBadgeCount} (= For you ${feed.viewCounts.mine})`);
|
||||
for (const item of feed.items) {
|
||||
const when = item.dismissal!.kind === "snooze" ? `snoozed, back ${item.dismissal!.snoozedUntil}` : `archived ${item.dismissal!.dismissedAt}`;
|
||||
console.log(` - ${when}${item.stale ? ` · stale ${item.stale.reason}` : ""}: ${item.subject.title}`);
|
||||
}
|
||||
}
|
||||
if (scottForHidden) {
|
||||
const actor = { type: "board", source: "session", userId: scottForHidden, companyIds: [company.id], isInstanceAdmin: true } as never;
|
||||
const history = decisionHistoryService(db);
|
||||
console.log("\n=== Decision history filters for Scott (task 2e) ===");
|
||||
for (const outcome of [undefined, "withdrawn", "expired", "issue_closed", "accepted", "rejected"]) {
|
||||
const page = await history.list(company.id, { actor, userId: scottForHidden, limit: 100, ...(outcome ? { outcome } : {}) });
|
||||
const approvals = page.items.filter((item) => item.sourceKind === "approval").length;
|
||||
console.log(`${(outcome ?? "all").padEnd(12)} ${String(page.items.length).padStart(3)} entr${page.items.length === 1 ? "y" : "ies"} (${approvals} approval${approvals === 1 ? "" : "s"})`);
|
||||
if (outcome) for (const item of page.items) console.log(` - ${item.sourceKind.padEnd(24)} ${item.label}${item.reason ? ` (reason: ${item.reason})` : ""}: ${item.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Task 2d: one card for each obligation. The feed keeps every card; the
|
||||
// page folds evidence into its lead, and the view counts and badge count
|
||||
// each obligation once.
|
||||
console.log("\n=== One card for each obligation (task 2d) ===");
|
||||
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
|
||||
const userId = idByEmail.get(email);
|
||||
if (!userId) continue;
|
||||
const shown: string[] = [];
|
||||
for (const view of ["mine", "unassigned", "company"] as const) {
|
||||
const feed = await svc.list(company.id, { userId, view, all: true, allowUnscopedAll: true });
|
||||
const leads = feed.items.filter((item) => !item.obligationLeadId || item.obligationLeadId === item.id);
|
||||
shown.push(`${view} ${feed.items.length} cards -> ${leads.length} shown (count ${feed.viewCounts[view]})`);
|
||||
}
|
||||
console.log(`${name}: ${shown.join("; ")}`);
|
||||
}
|
||||
if (scottForHidden) {
|
||||
const feed = await svc.list(company.id, { userId: scottForHidden, view: "all", all: true, allowUnscopedAll: true });
|
||||
for (const lead of feed.items.filter((item) => item.obligationKey && item.obligationLeadId === item.id)) {
|
||||
const place = lead.audience.kind === "user" ? (lead.audience.userId === scottForHidden ? "Scott" : lead.audience.userId) : lead.audience.kind;
|
||||
console.log(` ${lead.obligationKey} -> ${place}: ${lead.sourceKind} "${lead.subject.title}"`);
|
||||
for (const evidence of feed.items.filter((item) => item.obligationLeadId === lead.id && item !== lead)) {
|
||||
console.log(` + ${evidence.sourceKind} "${evidence.subject.title}"`);
|
||||
}
|
||||
}
|
||||
const gated = feed.items.filter((item) => item.subject.title?.startsWith("Independent gate") || item.subject.title === "Approve the reviewed task");
|
||||
for (const item of gated) console.log(` separate gate: ${item.sourceKind} "${item.subject.title}" (obligationKey ${item.obligationKey})`);
|
||||
}
|
||||
|
||||
// Task 1d: escalation owners. Cards whose "Why me?" is a project or budget
|
||||
// owner, and where the budget hard stop shows for each person.
|
||||
console.log("\n=== Escalation owners (task 1d) ===");
|
||||
const nameById = new Map([...idByEmail].map(([email, id]) => [id, Object.keys(DEMO_EMAILS).find((key) => DEMO_EMAILS[key] === email)!]));
|
||||
for (const [name, email] of Object.entries(DEMO_EMAILS)) {
|
||||
const userId = idByEmail.get(email);
|
||||
if (!userId) continue;
|
||||
const mine = await svc.list(company.id, { userId, view: "mine", all: true, allowUnscopedAll: true });
|
||||
const company_ = await svc.list(company.id, { userId, view: "company", all: true, allowUnscopedAll: true });
|
||||
const owned = mine.items.filter((item) => ["project_owner", "budget_owner", "escalation_owner"].includes(item.audience.reason));
|
||||
const hardStop = company_.items.find((item) => item.sourceKind === "budget_alert" && item.subject.metadata?.thresholdType === "hard");
|
||||
console.log(`${name}: ${owned.length} escalation card(s) in For you; Company shows the hard stop: ${hardStop ? `yes (for ${hardStop.audience.userId ? `@${nameById.get(hardStop.audience.userId)}` : "operators"})` : "no"}`);
|
||||
for (const item of owned) console.log(` - ${item.audience.reason.padEnd(16)} ${item.sourceKind}: ${item.subject.title} [${item.decisionVerbs.map((verb) => verb.label).join(" / ")}]`);
|
||||
}
|
||||
|
||||
// Per-source-kind breakdown for Scott's mine view, to show the composition.
|
||||
const scottId = idByEmail.get(DEMO_EMAILS.Scott);
|
||||
if (scottId) {
|
||||
for (const view of VIEWS) {
|
||||
const feed = await svc.list(company.id, { userId: scottId, view });
|
||||
const nonZero = Object.entries(feed.countsBySourceKind).filter(([, n]) => n > 0);
|
||||
console.log(`\n[Scott view=${view}] ${feed.totalCount} card(s): ${nonZero.map(([k, n]) => `${k}=${n}`).join(", ") || "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
await (db as unknown as { $client: { end: (o?: { timeout?: number }) => Promise<void> } }).$client.end({ timeout: 5 });
|
||||
} finally {
|
||||
await tempDb.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,927 @@
|
||||
/**
|
||||
* scripts/seed-needs-you-demo.ts
|
||||
*
|
||||
* Needs You demo seed for PAP-749 ("Plan fixes for Needs You"), task 0 (PAP-752),
|
||||
* plan section 7. It builds one synthetic demo company with three human board
|
||||
* members and every "Needs You" / attention card group listed in the plan's
|
||||
* section 7 table, so the multi-user authenticated preview can exercise the full
|
||||
* flow end to end.
|
||||
*
|
||||
* Design rules (from AGENTS.md + the plan):
|
||||
* - Uses the server services (not raw SQL) wherever a service exists; a few
|
||||
* attention sources have no public create service (failed runs, agent error,
|
||||
* join requests, escalated recovery, closed history) and are written directly
|
||||
* to their tables, matching how the product itself produces that state.
|
||||
* - Re-runnable: it deletes and recreates ONLY the demo company and its three
|
||||
* demo users. It never touches any other company.
|
||||
* - Stale cases are backdated by updating the source rows' timestamps.
|
||||
* - No product behavior changes here: this script lives under scripts/ and is
|
||||
* never imported by the app.
|
||||
*
|
||||
* Run it against the preview's OWN database (never this instance's data):
|
||||
* DATABASE_URL=postgres://... \
|
||||
* pnpm --filter @paperclipai/server exec tsx ../scripts/seed-needs-you-demo.ts
|
||||
*
|
||||
* Logins: the three demo users share one password, the DEMO_PASSWORD constant
|
||||
* below (override with SEED_DEMO_PASSWORD). Emails are listed in DEMO_USERS.
|
||||
*
|
||||
* Current cards (PAP-749 task 2a): archived/snoozed cards store the card's
|
||||
* `changeFingerprint`, read from the live feed exactly as the UI sends it, so
|
||||
* they return only on an important change. The pending cards on the done and
|
||||
* cancelled tasks are expired by the closed-task sweep at the next server
|
||||
* start; the approval on a done task stays (approvals are never auto-closed)
|
||||
* and shows the "Task closed" stale reason.
|
||||
*
|
||||
* Default decision owner (PAP-749 task 1d-lite): set SEED_DEFAULT_DECISION_OWNER
|
||||
* to a demo user key (scott|dotta|riley) to configure the company's default
|
||||
* decision owner. When set, the Unassigned-group cards route to that user's
|
||||
* "For you" list; when unset (the default) they stay in Unassigned. Re-run the
|
||||
* seed with and without it to compare the two behaviors.
|
||||
*
|
||||
* Views and Claim (PAP-749 task 1b): one ownerless card is already claimed by
|
||||
* Dotta through the claim service, with the same `attention.claimed` activity
|
||||
* entry the claim route writes. It shows in Dotta's "For you" as "Sent to you"
|
||||
* and in nobody's Unassigned pool.
|
||||
*
|
||||
* Close API (PAP-749 task 2b): the "Closed history" group holds an interaction
|
||||
* Dotta withdrew, a decision Scott withdrew and an approval Scott withdrew, made
|
||||
* through the same services the close endpoint calls, with matching
|
||||
* `attention.closed` entries. Riley still starts with no cards, so "transfer a
|
||||
* card to Riley" is a live step in the checklist.
|
||||
*
|
||||
* Receipts (PAP-749 task 2f): Dotta's For you opens with three receipts, made
|
||||
* the way the routes make them (the card's audience is read before the action
|
||||
* and written as `handledForUserId` + `receipt` on the activity entry): Scott
|
||||
* answered a confirmation on her task, withdrew her question with a reason
|
||||
* (the task shows unread in her Mine), and approved the approval she
|
||||
* requested. Transfer stays a live step, so Riley still starts with no cards.
|
||||
*
|
||||
* Archived view and history filters (PAP-749 task 2e): Scott's Archived tab
|
||||
* holds the archived cards (one of them also stale, 40 days without a change)
|
||||
* and, in its Snoozed group, the card snoozed until tomorrow. Decision history
|
||||
* now includes approvals: Dotta's approval Scott approved, the approval Scott
|
||||
* withdrew, and a strategy approval with no task that Scott rejected. The
|
||||
* Withdrawn, Expired and Closed with task filters each match their own rows;
|
||||
* the closed-with-task card is stored as `expired` like the product stores it,
|
||||
* and the Expired filter leaves it out.
|
||||
*
|
||||
* One card for each obligation (PAP-749 task 2d): Scott's For you shows the
|
||||
* approval with its in-review task as one card, and the failed run with the
|
||||
* task it blocked (blocked by exhausted retries, board-owned next action) as
|
||||
* one card with Retry; the run moves from Company to Scott with the task. A
|
||||
* task with a configured human review stage plus an approval stays two cards,
|
||||
* because the review is an independent quality gate.
|
||||
*
|
||||
* Project and budget escalation owners (PAP-749 task 1d): the "Platform"
|
||||
* project names Dotta as its decision owner, so its ownerless card goes to
|
||||
* Dotta's "For you" ("You own this project's decisions") before the company's
|
||||
* default decision owner. Scott is the budget owner, so the budget hard stop is
|
||||
* also in Scott's "For you" ("You own budget escalation", with Raise limit /
|
||||
* Keep paused) while every person's Company view still shows it; the 90%
|
||||
* warning stays in Company only. Toggle with SEED_PROJECT_DECISION_OWNER and
|
||||
* SEED_BUDGET_OWNER (scott|dotta|riley|none). Riley still starts with no cards.
|
||||
*/
|
||||
import { randomUUID, createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createDb,
|
||||
authUsers,
|
||||
authAccounts,
|
||||
authSessions,
|
||||
instanceUserRoles,
|
||||
companyMemberships,
|
||||
inboxDismissals,
|
||||
heartbeatRuns,
|
||||
heartbeatRunEvents,
|
||||
invites,
|
||||
joinRequests,
|
||||
issueRecoveryActions,
|
||||
issueThreadInteractions,
|
||||
decisions,
|
||||
decisionTargetIssues,
|
||||
issueApprovals,
|
||||
agents,
|
||||
approvals as approvalsTable,
|
||||
projects,
|
||||
issues as issuesTable,
|
||||
companies as companiesTable,
|
||||
} from "../packages/db/src/index.js";
|
||||
import { loadConfig } from "../server/src/config.js";
|
||||
import { companyService } from "../server/src/services/companies.js";
|
||||
import { accessService } from "../server/src/services/access.js";
|
||||
import { issueService } from "../server/src/services/issues.js";
|
||||
import { issueThreadInteractionService } from "../server/src/services/issue-thread-interactions.js";
|
||||
import { documentService } from "../server/src/services/documents.js";
|
||||
import { approvalService } from "../server/src/services/approvals.js";
|
||||
import { decisionService } from "../server/src/services/decisions.js";
|
||||
import { budgetService, flushBudgetEvaluationWork } from "../server/src/services/budgets.js";
|
||||
import { costService } from "../server/src/services/costs.js";
|
||||
import { issueRecoveryActionService } from "../server/src/services/issue-recovery-actions.js";
|
||||
import { inboxDismissalService } from "../server/src/services/inbox-dismissals.js";
|
||||
import { attentionService } from "../server/src/services/attention.js";
|
||||
import { attentionHandledDetails, attentionHandledFor } from "../server/src/services/attention-receipts.js";
|
||||
import { logActivity } from "../server/src/services/activity-log.js";
|
||||
import { createAgentLifecycle } from "../server/src/services/agent-lifecycle.js";
|
||||
|
||||
// drizzle-orm and better-auth are server-package dependencies; from a repo-root
|
||||
// script they only resolve through the server package's require graph.
|
||||
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
|
||||
const { eq, and, inArray, ne } = serverRequire("drizzle-orm") as typeof import("drizzle-orm");
|
||||
const { hashPassword } = (await import(
|
||||
pathToFileURL(serverRequire.resolve("better-auth/crypto")).href
|
||||
)) as { hashPassword: (password: string) => Promise<string> };
|
||||
|
||||
type Db = ReturnType<typeof createDb>;
|
||||
|
||||
const DEMO_COMPANY_NAME = "Needs You Demo";
|
||||
const DEMO_PASSWORD = process.env.SEED_DEMO_PASSWORD ?? "needs-you-demo-2026";
|
||||
|
||||
const DEMO_USERS = [
|
||||
{ key: "scott", name: "Scott", email: "scott@needs-you-demo.test", role: "owner" },
|
||||
{ key: "dotta", name: "Dotta", email: "dotta@needs-you-demo.test", role: "admin" },
|
||||
{ key: "riley", name: "Riley", email: "riley@needs-you-demo.test", role: "admin" },
|
||||
] as const;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const now = () => new Date();
|
||||
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
|
||||
const hoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const daysFromNow = (days: number) => new Date(Date.now() + days * DAY_MS);
|
||||
|
||||
function resolveDatabaseUrl(): string {
|
||||
const fromEnv = process.env.DATABASE_URL?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
const config = loadConfig();
|
||||
if (config.databaseUrl) return config.databaseUrl;
|
||||
return `postgres://paperclip:paperclip@127.0.0.1:${config.embeddedPostgresPort}/paperclip`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = resolveDatabaseUrl();
|
||||
const db: Db = createDb(url);
|
||||
console.log(`[seed] database: ${url.replace(/:[^:@/]*@/, ":***@")}`);
|
||||
|
||||
await teardown(db);
|
||||
const summary = await seed(db);
|
||||
|
||||
console.log("\n[seed] done. Demo company is ready.");
|
||||
console.log(`[seed] company: ${summary.companyName} (${summary.companyId}) prefix ${summary.prefix}`);
|
||||
console.log("[seed] users (shared password in DEMO_PASSWORD / SEED_DEMO_PASSWORD):");
|
||||
for (const u of summary.users) console.log(` - ${u.name} <${u.email}> [${u.role}]`);
|
||||
console.log(`[seed] cards created: ${summary.cardCount} (see group breakdown above)`);
|
||||
console.log(
|
||||
`[seed] default decision owner: ${summary.defaultDecisionOwner ?? "cleared (Unassigned)"} — toggle with SEED_DEFAULT_DECISION_OWNER`,
|
||||
);
|
||||
console.log(
|
||||
`[seed] budget owner: ${summary.budgetOwner ?? "cleared (Company only)"} — toggle with SEED_BUDGET_OWNER`,
|
||||
);
|
||||
console.log(
|
||||
`[seed] Platform project decision owner: ${summary.projectDecisionOwner ?? "cleared"} — toggle with SEED_PROJECT_DECISION_OWNER`,
|
||||
);
|
||||
await (db as unknown as { $client: { end: (o?: { timeout?: number }) => Promise<void> } }).$client.end({ timeout: 5 });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function teardown(db: Db) {
|
||||
console.log("[seed] teardown: removing any existing demo company + users...");
|
||||
const existing = await db
|
||||
.select({ id: companiesTable.id })
|
||||
.from(companiesTable)
|
||||
.where(eq(companiesTable.name, DEMO_COMPANY_NAME));
|
||||
for (const company of existing) {
|
||||
// company-deletion.ts does not delete these tables, and decisions.origin_run_id
|
||||
// keeps a FK onto heartbeat_runs that blocks the standard delete. Remove the
|
||||
// demo rows first (interactions/approvals/recovery also cascade from issues,
|
||||
// but deleting them here keeps teardown independent of that ordering).
|
||||
await db.delete(decisionTargetIssues).where(eq(decisionTargetIssues.companyId, company.id));
|
||||
await db.delete(decisions).where(eq(decisions.companyId, company.id));
|
||||
await db.delete(inboxDismissals).where(eq(inboxDismissals.companyId, company.id));
|
||||
await db.delete(issueApprovals).where(eq(issueApprovals.companyId, company.id));
|
||||
await db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.companyId, company.id));
|
||||
await db.delete(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, company.id));
|
||||
// company-deletion refuses to purge agents that are not terminated/rejected.
|
||||
await db.update(agents).set({ lifecycleState: "terminated", updatedAt: now() }).where(eq(agents.companyId, company.id));
|
||||
await companyService(db).remove(company.id);
|
||||
console.log(`[seed] removed company ${company.id}`);
|
||||
}
|
||||
|
||||
const emails = DEMO_USERS.map((u) => u.email);
|
||||
const demoUserRows = await db
|
||||
.select({ id: authUsers.id })
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.email, emails));
|
||||
const demoUserIds = demoUserRows.map((r) => r.id);
|
||||
if (demoUserIds.length > 0) {
|
||||
await db.delete(companyMemberships).where(inArray(companyMemberships.principalId, demoUserIds));
|
||||
await db.delete(instanceUserRoles).where(inArray(instanceUserRoles.userId, demoUserIds));
|
||||
await db.delete(inboxDismissals).where(inArray(inboxDismissals.userId, demoUserIds));
|
||||
await db.delete(authSessions).where(inArray(authSessions.userId, demoUserIds));
|
||||
await db.delete(authAccounts).where(inArray(authAccounts.userId, demoUserIds));
|
||||
await db.delete(authUsers).where(inArray(authUsers.id, demoUserIds));
|
||||
console.log(`[seed] removed ${demoUserIds.length} demo user(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
async function seed(db: Db) {
|
||||
const access = accessService(db);
|
||||
const interactions = issueThreadInteractionService(db);
|
||||
const approvalSvc = approvalService(db);
|
||||
const decisionsSvc = decisionService(db, { wakeOriginAgent: async () => {} });
|
||||
const issuesSvc = issueService(db);
|
||||
const recoverySvc = issueRecoveryActionService(db);
|
||||
const dismissals = inboxDismissalService(db);
|
||||
|
||||
let cardCount = 0;
|
||||
const card = (group: string, note: string) => {
|
||||
cardCount += 1;
|
||||
console.log(`[seed] + [${group}] ${note}`);
|
||||
};
|
||||
|
||||
// --- company -------------------------------------------------------------
|
||||
const company = await companyService(db).create({
|
||||
name: DEMO_COMPANY_NAME,
|
||||
description: "Synthetic demo for the Needs You multiplayer + stale-card plan (PAP-749).",
|
||||
status: "active",
|
||||
budgetMonthlyCents: 500_00,
|
||||
});
|
||||
const companyId = company.id;
|
||||
console.log(`[seed] created company ${companyId} (prefix ${company.issuePrefix})`);
|
||||
|
||||
// --- users ---------------------------------------------------------------
|
||||
const userIds: Record<string, string> = {};
|
||||
for (const u of DEMO_USERS) {
|
||||
const id = randomUUID();
|
||||
const ts = now();
|
||||
await db.insert(authUsers).values({
|
||||
id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
});
|
||||
await db.insert(authAccounts).values({
|
||||
id: randomUUID(),
|
||||
issuer: "local:credential",
|
||||
accountId: id,
|
||||
providerId: "credential",
|
||||
userId: id,
|
||||
password: await hashPassword(DEMO_PASSWORD),
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
});
|
||||
if (u.role === "owner") {
|
||||
await db.insert(instanceUserRoles).values({ userId: id, role: "instance_admin" }).onConflictDoNothing();
|
||||
}
|
||||
await access.ensureMembership(companyId, "user", id, u.role, "active");
|
||||
await access.ensureRoleDefaultGrants(companyId, id, u.role, id);
|
||||
userIds[u.key] = id;
|
||||
console.log(`[seed] created user ${u.name} <${u.email}> [${u.role}] ${id}`);
|
||||
}
|
||||
const scott = userIds.scott!;
|
||||
const dotta = userIds.dotta!;
|
||||
const riley = userIds.riley!;
|
||||
|
||||
// --- default decision owner (PAP-749 task 1d-lite) -----------------------
|
||||
// Toggle for the reviewer: SEED_DEFAULT_DECISION_OWNER accepts a demo user key
|
||||
// (scott|dotta|riley) to route ownerless cards to that person's "For you"
|
||||
// list; anything else (or unset / "none") leaves it cleared, so ownerless
|
||||
// cards stay in the claimable Unassigned pool. Re-run the seed with and
|
||||
// without it to compare set vs cleared.
|
||||
const defaultDecisionOwnerKey = (process.env.SEED_DEFAULT_DECISION_OWNER ?? "").trim().toLowerCase();
|
||||
const defaultDecisionOwnerId =
|
||||
defaultDecisionOwnerKey && defaultDecisionOwnerKey !== "none" ? userIds[defaultDecisionOwnerKey] ?? null : null;
|
||||
if (defaultDecisionOwnerId) {
|
||||
await companyService(db).update(companyId, { defaultDecisionOwnerUserId: defaultDecisionOwnerId });
|
||||
console.log(`[seed] default decision owner: ${defaultDecisionOwnerKey} (${defaultDecisionOwnerId}) — ownerless cards route to this user's For you`);
|
||||
} else {
|
||||
console.log('[seed] default decision owner: cleared — ownerless cards go to Unassigned. Set SEED_DEFAULT_DECISION_OWNER=dotta to compare.');
|
||||
}
|
||||
|
||||
// --- budget owner and project decision owner (PAP-749 task 1d) -----------
|
||||
// Defaults: Scott owns budget escalation and Dotta owns the Platform
|
||||
// project's decisions. "none" clears either one, so the hard stop stays in
|
||||
// Company only and the Platform card falls back to the default decision
|
||||
// owner or Unassigned.
|
||||
const ownerFromEnv = (name: string, fallback: string) => {
|
||||
const key = (process.env[name] ?? fallback).trim().toLowerCase();
|
||||
return { key, id: key && key !== "none" ? userIds[key] ?? null : null };
|
||||
};
|
||||
const budgetOwner = ownerFromEnv("SEED_BUDGET_OWNER", "scott");
|
||||
const projectDecisionOwner = ownerFromEnv("SEED_PROJECT_DECISION_OWNER", "dotta");
|
||||
if (budgetOwner.id) {
|
||||
await companyService(db).update(companyId, { budgetOwnerUserId: budgetOwner.id });
|
||||
console.log(`[seed] budget owner: ${budgetOwner.key} (${budgetOwner.id}) — the budget hard stop is also in this user's For you`);
|
||||
} else {
|
||||
console.log("[seed] budget owner: cleared — the budget hard stop is in Company only");
|
||||
}
|
||||
|
||||
// --- agents --------------------------------------------------------------
|
||||
const hire = (name: string, role: string, extra: Record<string, unknown> = {}) =>
|
||||
createAgentLifecycle(db).requestHire(companyId, {
|
||||
name,
|
||||
role,
|
||||
title: name,
|
||||
status: "idle",
|
||||
adapterType: "process",
|
||||
adapterConfig: { command: "echo", args: [name] },
|
||||
budgetMonthlyCents: 100_00,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const worker = await hire("Dottabot", "engineer");
|
||||
const pausedAgent = await hire("Napping Agent", "engineer");
|
||||
const failingAgent = await hire("Flaky Runner", "engineer");
|
||||
const erroredAgent = await hire("Broken Agent", "engineer");
|
||||
const hardStopAgent = await hire("Spendy Agent", "engineer");
|
||||
const warnAgent = await hire("Thrifty Agent", "engineer");
|
||||
|
||||
// project for the demo issues
|
||||
const projectId = await createProject(db, companyId, worker!.id);
|
||||
|
||||
// Helper: create an issue via the service. in_progress / in_review issues
|
||||
// require an assignee; default to the worker agent when none is specified.
|
||||
const makeIssue = async (input: Record<string, unknown>) => {
|
||||
const needsAssignee = input.status === "in_progress" || input.status === "in_review";
|
||||
const hasAssignee = Boolean(input.assigneeAgentId || input.assigneeUserId);
|
||||
const assigneeDefault = needsAssignee && !hasAssignee ? { assigneeAgentId: worker!.id } : {};
|
||||
const issue = await issuesSvc.create(companyId, { priority: "medium", projectId, ...assigneeDefault, ...input } as never);
|
||||
return issue as { id: string; title: string };
|
||||
};
|
||||
|
||||
// Helper: create an interaction. Returns the created row (with id).
|
||||
const makeInteraction = async (
|
||||
issue: { id: string },
|
||||
kind: string,
|
||||
payload: Record<string, unknown>,
|
||||
opts: { title?: string; summary?: string; addresseeUserId?: string | null; actorUserId?: string; actorAgentId?: string } = {},
|
||||
) => {
|
||||
const actor = opts.actorAgentId ? { agentId: opts.actorAgentId } : { userId: opts.actorUserId ?? scott };
|
||||
const created = await interactions.create(
|
||||
{ id: issue.id, companyId },
|
||||
{
|
||||
kind,
|
||||
payload: { version: 1, ...payload },
|
||||
title: opts.title,
|
||||
summary: opts.summary,
|
||||
addresseeUserId: opts.addresseeUserId ?? null,
|
||||
idempotencyKey: `seed:${issue.id}:${kind}:${randomUUID()}`,
|
||||
} as never,
|
||||
actor as never,
|
||||
);
|
||||
return created as { id: string };
|
||||
};
|
||||
|
||||
const reviewExecutionState = (userId: string) => ({
|
||||
status: "pending" as const,
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: "review" as const,
|
||||
currentParticipant: { type: "user" as const, userId },
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: null,
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: For you (Scott) — a question, a confirmation on a Scott task,
|
||||
// a review where Scott is reviewer, an approval Scott requested.
|
||||
// =========================================================================
|
||||
for (const [who, userId] of [["Scott", scott], ["Dotta", dotta]] as const) {
|
||||
const qIssue = await makeIssue({ title: `${who}: question needing an answer`, status: "todo", createdByUserId: userId, responsibleUserId: userId });
|
||||
await makeInteraction(qIssue, "ask_user_questions", {
|
||||
questions: [{ id: "pick", prompt: `${who}, which option should we ship?`, selectionMode: "single", required: true, options: [{ id: "a", label: "Option A" }, { id: "b", label: "Option B" }] }],
|
||||
}, { title: `A question for ${who}`, addresseeUserId: userId });
|
||||
card(`For you / ${who}`, "ask_user_questions sent to the user");
|
||||
|
||||
const cIssue = await makeIssue({ title: `${who}: task you created`, status: "in_progress", createdByUserId: userId, responsibleUserId: userId, assigneeAgentId: worker!.id });
|
||||
await makeInteraction(cIssue, "request_confirmation", { prompt: `${who}, confirm the plan for a task you created.` }, { title: `Confirm: ${who}'s task` });
|
||||
card(`For you / ${who}`, "request_confirmation on a task the user created (instigator)");
|
||||
|
||||
const rIssue = await makeIssue({ title: `${who}: review the launch checklist`, status: "todo", createdByUserId: userId });
|
||||
await issuesSvc.update(rIssue.id, { status: "in_review", assigneeUserId: userId, responsibleUserId: userId, executionState: reviewExecutionState(userId) } as never);
|
||||
card(`For you / ${who}`, "review with the user as the execution participant");
|
||||
|
||||
const aIssue = await makeIssue({ title: `${who}: spend approval`, status: "in_progress", createdByUserId: userId, responsibleUserId: userId });
|
||||
const approval = await approvalSvc.create(companyId, {
|
||||
type: "request_board_approval",
|
||||
status: "pending",
|
||||
requestedByUserId: userId,
|
||||
payload: { title: `Approve ${who}'s request`, summary: `Release demo budget for ${who}.` },
|
||||
} as never);
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: aIssue.id, approvalId: (approval as { id: string }).id });
|
||||
card(`For you / ${who}`, "request_board_approval requested by the user");
|
||||
}
|
||||
|
||||
// A plan approval whose ask mentions a second document (PAP-775): the plan
|
||||
// and the mentioned design notes each open as a side-panel tab.
|
||||
{
|
||||
const docs = documentService(db);
|
||||
const planIssue = await makeIssue({ title: "Scott: approve the onboarding plan", status: "in_progress", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id }) as { id: string; identifier?: string };
|
||||
const plan = await docs.upsertIssueDocument({ issueId: planIssue.id, key: "plan", title: "Onboarding plan", format: "markdown",
|
||||
body: "# Onboarding plan\n\n1. Send the welcome email on day one.\n2. Pair each new member with a buddy.\n3. Review progress after two weeks.", createdByAgentId: worker!.id });
|
||||
await docs.upsertIssueDocument({ issueId: planIssue.id, key: "design", title: "Design notes", format: "markdown",
|
||||
body: "# Design notes\n\nThe welcome email reuses the existing template. The buddy list comes from the team directory.", createdByAgentId: worker!.id });
|
||||
await makeInteraction(planIssue, "request_confirmation", {
|
||||
prompt: `Approve the onboarding plan? It follows the [design notes](/NEE/issues/${planIssue.identifier}#document-design).`,
|
||||
acceptLabel: "Approve plan", rejectLabel: "Request changes", rejectRequiresReason: true,
|
||||
target: { type: "issue_document", issueId: planIssue.id, key: "plan", revisionId: plan.document.latestRevisionId },
|
||||
}, { title: "Approve the onboarding plan", actorAgentId: worker!.id, addresseeUserId: scott });
|
||||
card("For you / Scott", "plan approval that mentions a second document");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Card designs (PAP-775, Paper "A · Compact cards") — one card for
|
||||
// each design the groups above don't already cover. Demo only:
|
||||
// approving the tool or connection card runs nothing real.
|
||||
// =========================================================================
|
||||
{
|
||||
const docs = documentService(db);
|
||||
const emailIssue = await makeIssue({ title: "Welcome email", status: "in_review", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
|
||||
const draft = await docs.upsertIssueDocument({ issueId: emailIssue.id, key: "email-draft", title: "Welcome to the team", format: "markdown",
|
||||
body: "# Welcome to the team\n\nHi {first name}, we're glad you're here. Your first step: say hello to your buddy.", createdByAgentId: worker!.id });
|
||||
await makeInteraction(emailIssue, "request_confirmation", {
|
||||
prompt: "A shorter intro and a clear first step, ready for the pilot. Approval marks the draft ready; sending is a separate decision.",
|
||||
acceptLabel: "Approve draft", rejectLabel: "Request changes",
|
||||
target: { type: "issue_document", issueId: emailIssue.id, key: "email-draft", revisionId: draft.document.latestRevisionId },
|
||||
}, { title: "Review the welcome email draft", actorAgentId: worker!.id, addresseeUserId: scott });
|
||||
card("Card designs / Scott", "deliverable review (a document that is not the plan)");
|
||||
|
||||
const followUps = await makeIssue({ title: "Pilot follow-ups", status: "in_progress", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
|
||||
await makeInteraction(followUps, "suggest_tasks", {
|
||||
tasks: [
|
||||
{ clientKey: "email", title: "Write the welcome email", description: "Communications" },
|
||||
{ clientKey: "buddy", title: "Create the buddy matching checklist", description: "Operations" },
|
||||
{ clientKey: "survey", title: "Draft the two-week survey", description: "Research" },
|
||||
],
|
||||
}, { title: "Choose tasks to create", summary: "Choose which follow-ups to create in the Onboarding project.", actorAgentId: worker!.id, addresseeUserId: scott });
|
||||
card("Card designs / Scott", "suggested tasks for Scott");
|
||||
|
||||
const releaseIssue = await makeIssue({ title: "Publish pilot assets", status: "in_progress", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
|
||||
await makeInteraction(releaseIssue, "request_confirmation", {
|
||||
prompt: "The agent needs the protected GitHub release tool and its saved credential.",
|
||||
toolAction: { version: 1, actionRequestId: randomUUID(), invocationId: randomUUID(), toolName: "github.create_release", toolDisplayName: "Create a release",
|
||||
connectionId: null, applicationId: null, appDisplayName: "GitHub", risk: "write", previewMarkdown: "acme/onboarding · Tag: pilot-v1",
|
||||
argumentsSummaryJson: JSON.stringify({ repo: "acme/onboarding", tag: "pilot-v1" }), argumentsHash: randomUUID(), expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() },
|
||||
}, { title: "Allow the worker to publish the pilot release?", actorAgentId: worker!.id, addresseeUserId: scott });
|
||||
card("Card designs / Scott", "tool access approval");
|
||||
|
||||
const sessionsIssue = await makeIssue({ title: "Schedule pilot sessions", status: "blocked", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: worker!.id });
|
||||
const connect = await makeInteraction(sessionsIssue, "request_confirmation", { prompt: "Connect Google Calendar" }, { title: "Connect Google Calendar", actorAgentId: worker!.id, addresseeUserId: scott });
|
||||
// Only the server authors a connection request, so write it as the server would.
|
||||
await db.update(issueThreadInteractions).set({ kind: "connection_intent",
|
||||
summary: "Connect an account to schedule the onboarding sessions. You'll sign in with Google and review the requested permissions.",
|
||||
payload: { version: 1, serviceSlug: "google-calendar", serviceName: "Google Calendar", requestingAgentId: worker!.id, requestingAgentName: "Dottabot", phase: "requested" } })
|
||||
.where(eq(issueThreadInteractions.id, connect.id));
|
||||
card("Card designs / Scott", "connect a service");
|
||||
|
||||
await approvalSvc.create(companyId, {
|
||||
type: "hire_agent", status: "pending", requestedByAgentId: worker!.id,
|
||||
payload: { name: "Researcher", role: "researcher", title: "Research role", capabilities: "Prepare and summarize the onboarding interviews.", adapterType: "claude_local", budgetMonthlyCents: 5000 },
|
||||
} as never);
|
||||
card("Card designs / Company", "agent hire approval");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Child-task lineage — a child of a Scott task, created by an agent,
|
||||
// with a review for Scott.
|
||||
// =========================================================================
|
||||
const parentIssue = await makeIssue({ title: "Scott: parent initiative", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const childIssue = await makeIssue({ title: "Agent-built child task (review for Scott)", status: "todo", parentId: parentIssue.id, createdByAgentId: worker!.id });
|
||||
await issuesSvc.update(childIssue.id, { status: "in_review", assigneeUserId: scott, responsibleUserId: scott, executionState: reviewExecutionState(scott) } as never);
|
||||
card("Child-task lineage", "agent-created child of a Scott task, review routed to Scott");
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Unassigned — routine/agent-created work with no addressee, plus a
|
||||
// card for an agent that cannot run (paused).
|
||||
// =========================================================================
|
||||
const routineIssue = await makeIssue({ title: "Routine-created work (no human owner)", status: "todo", createdByAgentId: worker!.id, originKind: "routine" });
|
||||
await makeInteraction(routineIssue, "request_confirmation", { prompt: "Confirm the routine output. No explicit human owner." }, { title: "Ownerless: routine output", actorAgentId: worker!.id });
|
||||
card("Unassigned", "agent/routine-created confirmation with no addressee");
|
||||
|
||||
const agentIssue = await makeIssue({ title: "Agent-created work (no addressee)", status: "todo", createdByAgentId: worker!.id });
|
||||
await makeInteraction(agentIssue, "suggest_tasks", { tasks: [{ clientKey: "s1", title: "Follow-up task suggested by an agent" }] }, { title: "Ownerless: suggested tasks", actorAgentId: worker!.id });
|
||||
card("Unassigned", "agent-created suggest_tasks with no addressee");
|
||||
|
||||
const cannotRunIssue = await makeIssue({ title: "Request to an agent that cannot run", status: "in_progress", assigneeAgentId: worker!.id, createdByAgentId: worker!.id });
|
||||
const cannotRun = await makeInteraction(cannotRunIssue, "request_confirmation", { prompt: "Agent cannot run; a human must pick this up." }, { title: "Agent paused — needs a human", actorAgentId: worker!.id });
|
||||
// The create path refuses a non-invokable addressee, and the feed re-evaluates
|
||||
// invokability at read time, so set the (paused) agent addressee directly.
|
||||
await db.update(agents).set({ status: "paused", updatedAt: now() }).where(eq(agents.id, pausedAgent!.id));
|
||||
await db.update(issueThreadInteractions).set({ addresseeAgentId: pausedAgent!.id }).where(eq(issueThreadInteractions.id, cannotRun.id));
|
||||
card("Unassigned", "request addressed to a paused (non-invokable) agent");
|
||||
|
||||
// Claimed (task 1b): an ownerless card Dotta took from Unassigned. The claim
|
||||
// makes Dotta the addressee, so it leaves every other person's pool.
|
||||
const claimedIssue = await makeIssue({ title: "Agent-created work Dotta claimed", status: "todo", createdByAgentId: worker!.id });
|
||||
const claimedCard = await makeInteraction(claimedIssue, "request_confirmation", { prompt: "Dotta claimed this from Unassigned." }, { title: "Claimed by Dotta", actorAgentId: worker!.id });
|
||||
const claim = await attentionService(db).claim(companyId, {
|
||||
sourceKind: "issue_thread_interaction", sourceId: claimedCard.id, userId: dotta, actor: await boardActorFor(db, companyId, dotta),
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId, actorType: "user", actorId: dotta, action: "attention.claimed", entityType: "issue", entityId: claim.issueId, issueId: claim.issueId,
|
||||
details: { sourceKind: claim.sourceKind, sourceId: claim.sourceId, interactionId: claim.sourceId, claimedByUserId: dotta, previousAddresseeAgentId: null },
|
||||
});
|
||||
card("Claimed", "ownerless card Dotta claimed (in Dotta's For you, not in Unassigned)");
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Project owner (task 1d) — agent-created work with no human signal
|
||||
// in a project that names a decision owner.
|
||||
// =========================================================================
|
||||
const platformProjectId = await createProject(db, companyId, worker!.id, {
|
||||
name: "Platform",
|
||||
description: "Its decision owner gets the project's ownerless cards.",
|
||||
decisionOwnerUserId: projectDecisionOwner.id,
|
||||
});
|
||||
const platformIssue = await issuesSvc.create(companyId, {
|
||||
title: "Agent-created Platform work (no addressee)", status: "todo", priority: "medium",
|
||||
projectId: platformProjectId, createdByAgentId: worker!.id,
|
||||
} as never) as { id: string };
|
||||
await makeInteraction(platformIssue, "request_confirmation", { prompt: "Confirm the Platform migration plan. No explicit human owner." }, {
|
||||
title: "Platform: confirm the migration plan", actorAgentId: worker!.id,
|
||||
});
|
||||
card(
|
||||
"Project owner",
|
||||
projectDecisionOwner.id
|
||||
? `ownerless Platform card in ${projectDecisionOwner.key}'s For you ("You own this project's decisions")`
|
||||
: "ownerless Platform card (no project decision owner: default owner or Unassigned)",
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Company — failed run after retries, agent error, budget hard stop,
|
||||
// budget warning at 90%, join request, recovery that stopped.
|
||||
// =========================================================================
|
||||
// failed run (after bounded-retry exhaustion)
|
||||
{
|
||||
const [run] = await db.insert(heartbeatRuns).values({
|
||||
companyId, agentId: failingAgent!.id, scopeKind: "company", status: "failed",
|
||||
invocationSource: "on_demand", error: "Task failed after all retries.", startedAt: hoursAgo(3), finishedAt: hoursAgo(2),
|
||||
}).returning();
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId, runId: run!.id, agentId: failingAgent!.id, seq: 1, eventType: "lifecycle",
|
||||
message: "Bounded retry exhausted after 3 scheduled attempts; no further retries will run.", createdAt: hoursAgo(2),
|
||||
});
|
||||
card("Company", "failed run after bounded-retry exhaustion");
|
||||
}
|
||||
// agent error
|
||||
await db.update(agents).set({ status: "error", errorReason: "Adapter crashed during startup.", updatedAt: now() }).where(eq(agents.id, erroredAgent!.id));
|
||||
card("Company", "agent in error state");
|
||||
|
||||
// budget hard stop (agent-scoped so it does not pause the whole demo company)
|
||||
await budgetService(db).upsertPolicy(companyId, { scopeType: "agent", scopeId: hardStopAgent!.id, amount: 100_00, warnPercent: 80, hardStopEnabled: true, notifyEnabled: true } as never, scott);
|
||||
await costService(db).createEvent(companyId, { agentId: hardStopAgent!.id, provider: "anthropic", model: "claude-opus-4-8", usageKind: "agent", costCents: 100_00, occurredAt: now() } as never);
|
||||
card("Company", budgetOwner.id
|
||||
? `budget hard stop (100%) incident — also in ${budgetOwner.key}'s For you as the budget owner`
|
||||
: "budget hard stop (100%) incident");
|
||||
|
||||
// budget warning at ~90%
|
||||
await budgetService(db).upsertPolicy(companyId, { scopeType: "agent", scopeId: warnAgent!.id, amount: 100_00, warnPercent: 80, hardStopEnabled: false, notifyEnabled: true } as never, scott);
|
||||
await costService(db).createEvent(companyId, { agentId: warnAgent!.id, provider: "anthropic", model: "claude-opus-4-8", usageKind: "agent", costCents: 90_00, occurredAt: now() } as never);
|
||||
// This independent seed process must finish both hard-stop and warning work
|
||||
// before handing the demo to a server whose notifier did not write the costs.
|
||||
await flushBudgetEvaluationWork(db, { companyId });
|
||||
card("Company", "budget warning (~90%) incident");
|
||||
|
||||
// join request (pending approval)
|
||||
{
|
||||
const [invite] = await db.insert(invites).values({
|
||||
companyId, inviteType: "company_join", tokenHash: createHash("sha256").update(`seed-invite-${randomUUID()}`).digest("hex"),
|
||||
allowedJoinTypes: "both", expiresAt: daysFromNow(7), acceptedAt: now(),
|
||||
}).returning();
|
||||
await db.insert(joinRequests).values({
|
||||
inviteId: invite!.id, companyId, requestType: "human", status: "pending_approval", requestIp: "127.0.0.1",
|
||||
requestEmailSnapshot: "newcomer@needs-you-demo.test",
|
||||
});
|
||||
card("Company", "pending join request");
|
||||
}
|
||||
|
||||
// recovery that stopped (escalated to a human owner)
|
||||
{
|
||||
const recoveryIssue = await makeIssue({ title: "Run recovery escalated to the board", status: "blocked", createdByAgentId: worker!.id });
|
||||
await db.insert(issueRecoveryActions).values({
|
||||
companyId, sourceIssueId: recoveryIssue.id, kind: "stalled_run", status: "escalated", ownerType: "board",
|
||||
cause: "run_stalled", fingerprint: `seed-${randomUUID()}`, evidence: { note: "Recovery stopped; a human must decide next steps." },
|
||||
nextAction: "A human must decide how to recover this stalled run.",
|
||||
});
|
||||
card("Company", "recovery action escalated to the board");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Stale — 31 & 60 days, done task, cancelled task, decision at ~85%
|
||||
// of its TTL, and a stale approval that must escalate.
|
||||
// =========================================================================
|
||||
for (const age of [31, 60]) {
|
||||
const staleIssue = await makeIssue({ title: `Stale ${age}-day confirmation`, status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const stale = await makeInteraction(staleIssue, "request_confirmation", { prompt: `This has waited ${age} days.` }, { title: `Stale (${age}d)` });
|
||||
await backdateInteraction(db, stale.id, daysAgo(age));
|
||||
card("Stale", `${age}-day-old pending confirmation`);
|
||||
}
|
||||
{
|
||||
const doneIssue = await makeIssue({ title: "Confirmation on a done task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const onDone = await makeInteraction(doneIssue, "request_confirmation", { prompt: "The task is done but this card lingers." }, { title: "Lingering on a done task" });
|
||||
await db.update(issuesTable).set({ status: "done", updatedAt: daysAgo(5) }).where(eq(issuesTable.id, doneIssue.id));
|
||||
await backdateInteraction(db, onDone.id, daysAgo(5));
|
||||
card("Stale", "pending card on a done task (expired by the sweep at the next server start)");
|
||||
}
|
||||
{
|
||||
const cancelledIssue = await makeIssue({ title: "Confirmation on a cancelled task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const onCancelled = await makeInteraction(cancelledIssue, "request_confirmation", { prompt: "The task is cancelled but this card lingers." }, { title: "Lingering on a cancelled task" });
|
||||
await db.update(issuesTable).set({ status: "cancelled", updatedAt: daysAgo(5) }).where(eq(issuesTable.id, cancelledIssue.id));
|
||||
await backdateInteraction(db, onCancelled.id, daysAgo(5));
|
||||
card("Stale", "pending card on a cancelled task (expired by the sweep at the next server start)");
|
||||
}
|
||||
{
|
||||
// The sweep never closes approvals, so this one keeps the "Task closed" reason.
|
||||
const doneApprovalIssue = await makeIssue({ title: "Approval left on a done task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const doneApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Approval on a finished task", summary: "The task is done; the approval still waits for a person." } } as never);
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: doneApprovalIssue.id, approvalId: (doneApproval as { id: string }).id });
|
||||
await db.update(issuesTable).set({ status: "done", updatedAt: daysAgo(3) }).where(eq(issuesTable.id, doneApprovalIssue.id));
|
||||
card("Stale", "approval on a done task (stale: Task closed; never auto-withdrawn)");
|
||||
}
|
||||
{
|
||||
// Unassigned for 8 days: moves to Company with "No owner for 7+ days".
|
||||
const unownedIssue = await makeIssue({ title: "Agent work with no owner for 8 days", status: "todo", createdByAgentId: worker!.id });
|
||||
const unowned = await makeInteraction(unownedIssue, "request_confirmation", { prompt: "Nobody claimed this for over a week." }, { title: "Ownerless for 8 days", actorAgentId: worker!.id });
|
||||
await backdateInteraction(db, unowned.id, daysAgo(8));
|
||||
card("Stale", "Unassigned card older than 7 days (moves to Company: No owner for 7+ days)");
|
||||
}
|
||||
// decision at ~85% of a 7-day TTL (created 6 days ago, expires ~1 day out)
|
||||
const decisionIssue = await makeIssue({ title: "Decision nearing its deadline", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const boardActor = await boardActorFor(db, companyId, scott);
|
||||
const decisionRun = await makeOriginRun(db, companyId, worker!.id, decisionIssue.id, scott);
|
||||
const nearDeadline = await decisionsSvc.create({
|
||||
companyId, actor: boardActor, agentId: worker!.id, runId: decisionRun,
|
||||
title: "Choose the rollout plan (deadline soon)", body: "This decision is ~85% through its time limit.",
|
||||
options: [{ id: "staged", label: "Staged rollout", effects: [] }, { id: "bigbang", label: "All at once", style: "destructive", effects: [] }],
|
||||
expiresAt: daysFromNow(1),
|
||||
} as never);
|
||||
await db.update(decisions).set({ createdAt: daysAgo(6), updatedAt: daysAgo(6) }).where(eq(decisions.id, (nearDeadline as { id: string }).id));
|
||||
card("Stale", "open decision at ~85% of its TTL");
|
||||
// stale approval that must escalate (35 days old)
|
||||
{
|
||||
const staleApprovalIssue = await makeIssue({ title: "Approval waiting 35+ days", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const staleApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Stale approval (35 days)", summary: "This approval has waited over 30 days and must escalate." } } as never);
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: staleApprovalIssue.id, approvalId: (staleApproval as { id: string }).id });
|
||||
await db.update(approvalsTable).set({ createdAt: daysAgo(35), updatedAt: daysAgo(35) }).where(eq(approvalsTable.id, (staleApproval as { id: string }).id));
|
||||
card("Stale", "approval older than 30 days (must escalate, never auto-withdrawn)");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Archived & snoozed (Scott) — two archived, one snoozed until
|
||||
// tomorrow, one archived card with a newer change that must come back.
|
||||
// =========================================================================
|
||||
// Hide each card with the fingerprint Scott's feed shows for it, exactly as
|
||||
// the UI sends it (PAP-749 Rule 9).
|
||||
const hiddenCards: Array<{ id: string; mode: "archive" | "snooze" }> = [];
|
||||
for (const i of [1, 2]) {
|
||||
const archIssue = await makeIssue({ title: `Archived by Scott #${i}`, status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const arch = await makeInteraction(archIssue, "request_confirmation", { prompt: `Scott archived this card (#${i}).` }, { title: `Archived #${i}`, addresseeUserId: scott });
|
||||
hiddenCards.push({ id: arch.id, mode: "archive" });
|
||||
card("Archived/Snoozed", `card archived by Scott (#${i})`);
|
||||
}
|
||||
const snoozeIssue = await makeIssue({ title: "Snoozed by Scott until tomorrow", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const snoozed = await makeInteraction(snoozeIssue, "request_confirmation", { prompt: "Scott snoozed this until tomorrow." }, { title: "Snoozed", addresseeUserId: scott });
|
||||
hiddenCards.push({ id: snoozed.id, mode: "snooze" });
|
||||
card("Archived/Snoozed", "card snoozed by Scott until tomorrow");
|
||||
const comebackIssue = await makeIssue({ title: "Archived, then changed — must come back", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const comeback = await makeInteraction(comebackIssue, "request_confirmation", { prompt: "Archived earlier, then the agent changed the proposal." }, { title: "Archived with a new change", addresseeUserId: scott });
|
||||
hiddenCards.push({ id: comeback.id, mode: "archive" });
|
||||
card("Archived/Snoozed", "archived card whose proposal changed (comes back)");
|
||||
const logOnlyIssue = await makeIssue({ title: "Archived, then only log activity — stays hidden", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const logOnly = await makeInteraction(logOnlyIssue, "request_confirmation", { prompt: "Archived; later activity is only log noise." }, { title: "Archived with log-only activity", addresseeUserId: scott });
|
||||
hiddenCards.push({ id: logOnly.id, mode: "archive" });
|
||||
card("Archived/Snoozed", "archived card with only log activity (stays hidden)");
|
||||
// Task 2e: the Archived view shows the archive date and whether the card is also stale.
|
||||
const archivedStaleIssue = await makeIssue({ title: "Archived and stale (40 days)", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const archivedStale = await makeInteraction(archivedStaleIssue, "request_confirmation", { prompt: "Archived a while ago; nothing changed for 40 days." }, { title: "Archived and stale", addresseeUserId: scott });
|
||||
await backdateInteraction(db, archivedStale.id, daysAgo(40));
|
||||
hiddenCards.push({ id: archivedStale.id, mode: "archive" });
|
||||
card("Archived/Snoozed", "archived card that is also stale (Archived view: archive date + Stale chip)");
|
||||
|
||||
const scottFeed = await attentionService(db).list(companyId, { userId: scott, all: true, allowUnscopedAll: true });
|
||||
const feedBySourceId = new Map(scottFeed.items.map((item) => [item.subject.id, item]));
|
||||
for (const hidden of hiddenCards) {
|
||||
const item = feedBySourceId.get(hidden.id);
|
||||
if (!item) throw new Error(`[seed] card ${hidden.id} missing from Scott's feed; cannot archive it`);
|
||||
if (hidden.mode === "snooze") {
|
||||
await dismissals.snooze(companyId, scott, item.dismissalKey, daysFromNow(1), hoursAgo(2), item.changeFingerprint);
|
||||
} else {
|
||||
await dismissals.dismiss(companyId, scott, item.dismissalKey, hoursAgo(2), item.changeFingerprint);
|
||||
}
|
||||
}
|
||||
// After archiving: a new proposal is an important change, so the card returns.
|
||||
await db.update(issueThreadInteractions)
|
||||
.set({ payload: { version: 1, prompt: "Revised proposal: ship behind a flag first?" }, updatedAt: now() })
|
||||
.where(eq(issueThreadInteractions.id, comeback.id));
|
||||
// Activity alone (log entries, retries, comments) is not, so this card stays hidden.
|
||||
await db.update(issueThreadInteractions).set({ updatedAt: now() }).where(eq(issueThreadInteractions.id, logOnly.id));
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Closed (history) — answered, withdrawn, expired, closed-with-task.
|
||||
// =========================================================================
|
||||
{
|
||||
const histAnswered = await makeIssue({ title: "History: answered", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const a = await makeInteraction(histAnswered, "request_confirmation", { prompt: "Answered in the past." }, { title: "Answered" });
|
||||
await db.update(issueThreadInteractions).set({ status: "answered", resolvedAt: daysAgo(3), resolvedByUserId: scott, result: { outcome: "resolved", decision: "accept" }, updatedAt: daysAgo(3) }).where(eq(issueThreadInteractions.id, a.id));
|
||||
card("Closed history", "answered interaction");
|
||||
|
||||
// Withdrawn through the same services the close endpoint calls (task 2b),
|
||||
// each with the `attention.closed` entry the route writes. History shows
|
||||
// "Withdrawn by user" and the reason.
|
||||
const closedEntry = async (userId: string, sourceKind: string, sourceId: string, issueId: string, reason: string) =>
|
||||
logActivity(db, {
|
||||
companyId, actorType: "user", actorId: userId, action: "attention.closed", entityType: "issue", entityId: issueId, issueId,
|
||||
details: { sourceKind, sourceId, mode: "withdraw", reason, until: null, closedByUserId: userId },
|
||||
});
|
||||
const histWithdrawn = await makeIssue({ title: "History: withdrawn by Dotta", status: "todo", createdByUserId: scott, responsibleUserId: scott });
|
||||
const w = await makeInteraction(histWithdrawn, "request_confirmation", { prompt: "Withdrawn in the past." }, { title: "Withdrawn" });
|
||||
await interactions.withdrawInteraction({ id: histWithdrawn.id, companyId }, w.id, { reason: "No longer needed." }, { userId: dotta });
|
||||
await db.update(issueThreadInteractions).set({ resolvedAt: daysAgo(2), updatedAt: daysAgo(2) }).where(eq(issueThreadInteractions.id, w.id));
|
||||
await closedEntry(dotta, "issue_thread_interaction", w.id, histWithdrawn.id, "No longer needed.");
|
||||
card("Closed history", "interaction Dotta withdrew (Withdrawn by user)");
|
||||
|
||||
const histDecisionIssue = await makeIssue({ title: "History: decision withdrawn by Scott", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const histDecisionRun = await makeOriginRun(db, companyId, worker!.id, histDecisionIssue.id, scott);
|
||||
const histDecision = await decisionsSvc.create({
|
||||
companyId, actor: await boardActorFor(db, companyId, scott), agentId: worker!.id, runId: histDecisionRun,
|
||||
title: "Pick a launch date (withdrawn)", body: "Scott withdrew this because the launch moved.",
|
||||
options: [{ id: "monday", label: "Monday", effects: [] }, { id: "friday", label: "Friday", effects: [] }],
|
||||
} as never) as { id: string };
|
||||
await decisionsSvc.cancel(histDecision.id, { actorType: "user", actorId: scott }, { reason: "The launch moved; this is out of date." });
|
||||
await closedEntry(scott, "decision", histDecision.id, histDecisionIssue.id, "The launch moved; this is out of date.");
|
||||
card("Closed history", "decision Scott withdrew (Withdrawn by user)");
|
||||
|
||||
const histApprovalIssue = await makeIssue({ title: "History: approval withdrawn by Scott", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const histApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: dotta, payload: { title: "Approve the old vendor (withdrawn)", summary: "Withdrawn as out of date; never approved." } } as never) as { id: string };
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: histApprovalIssue.id, approvalId: histApproval.id });
|
||||
await approvalSvc.cancel(histApproval.id, "We picked a different vendor.", { cancelledByUserId: scott });
|
||||
await closedEntry(scott, "approval", histApproval.id, histApprovalIssue.id, "We picked a different vendor.");
|
||||
card("Closed history", "approval Scott withdrew (history: \"Withdrawn by @Scott\" with the reason)");
|
||||
|
||||
// Task 2e: approvals are in history. One with no task, rejected by Scott.
|
||||
const histRejected = await approvalSvc.create(companyId, { type: "approve_ceo_strategy", status: "pending", requestedByUserId: dotta, payload: { title: "Adopt the 2027 pricing strategy", summary: "Rejected; the board kept current pricing." } } as never) as { id: string };
|
||||
await approvalSvc.reject(histRejected.id, scott, "Keep current pricing for now.");
|
||||
await db.update(approvalsTable).set({ decidedAt: daysAgo(4), updatedAt: daysAgo(4) }).where(eq(approvalsTable.id, histRejected.id));
|
||||
card("Closed history", "approval with no task that Scott rejected (history: \"Approval · …\")");
|
||||
|
||||
const histExpired = await makeIssue({ title: "History: expired", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const e = await makeInteraction(histExpired, "request_confirmation", { prompt: "Expired in the past." }, { title: "Expired" });
|
||||
await db.update(issueThreadInteractions).set({ status: "expired", resolvedAt: daysAgo(1), result: { outcome: "expired" }, updatedAt: daysAgo(1) }).where(eq(issueThreadInteractions.id, e.id));
|
||||
card("Closed history", "expired interaction");
|
||||
|
||||
const histClosedWithTask = await makeIssue({ title: "History: closed with its task", status: "in_progress", createdByUserId: scott, responsibleUserId: scott });
|
||||
const c = await makeInteraction(histClosedWithTask, "request_confirmation", { prompt: "Closed when its task closed." }, { title: "Closed with task" });
|
||||
// Stored as `expired` with outcome `issue_closed`, the shape the product
|
||||
// writes when a task closes; the Expired filter leaves it out (task 2e).
|
||||
await db.update(issueThreadInteractions).set({ status: "expired", resolvedAt: daysAgo(1), result: { version: 1, outcome: "issue_closed" }, updatedAt: daysAgo(1) }).where(eq(issueThreadInteractions.id, c.id));
|
||||
await db.update(issuesTable).set({ status: "done", updatedAt: daysAgo(1) }).where(eq(issuesTable.id, histClosedWithTask.id));
|
||||
card("Closed history", "interaction closed with its task");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: Receipts for Dotta (task 2f) — what Scott did with Dotta's cards.
|
||||
// =========================================================================
|
||||
{
|
||||
const handledFor = (sourceKind: "issue_thread_interaction" | "approval", sourceId: string) =>
|
||||
attentionHandledFor(db, companyId, { sourceKind, sourceId }, scott);
|
||||
|
||||
const answeredIssue = await makeIssue({ title: "Dotta: launch checklist", status: "todo", createdByUserId: dotta, responsibleUserId: dotta });
|
||||
const answered = await makeInteraction(answeredIssue, "request_confirmation", { prompt: "Is the launch checklist complete?" }, { title: "Confirm the launch checklist" });
|
||||
const answeredFor = await handledFor("issue_thread_interaction", answered.id);
|
||||
await db.update(issueThreadInteractions).set({ status: "accepted", resolvedAt: now(), resolvedByUserId: scott, result: { outcome: "accepted" }, updatedAt: now() }).where(eq(issueThreadInteractions.id, answered.id));
|
||||
await logActivity(db, {
|
||||
companyId, actorType: "user", actorId: scott, action: "issue.thread_interaction_accepted", entityType: "issue", entityId: answeredIssue.id,
|
||||
details: { interactionId: answered.id, interactionKind: "request_confirmation", interactionStatus: "accepted", ...attentionHandledDetails(answeredFor, "approved") },
|
||||
});
|
||||
card("Receipts", "Dotta: \"Approved by @Scott\" (confirmation on her task)");
|
||||
|
||||
const withdrawnIssue = await makeIssue({ title: "Dotta: vendor shortlist", status: "todo", createdByUserId: dotta, responsibleUserId: dotta });
|
||||
const withdrawn = await makeInteraction(withdrawnIssue, "ask_user_questions", {
|
||||
questions: [{ id: "vendor", prompt: "Which vendor should we shortlist?", selectionMode: "single", options: [{ id: "a", label: "Acme" }, { id: "b", label: "Globex" }] }],
|
||||
}, { title: "Pick a vendor to shortlist" });
|
||||
// Dotta read the task first, so the withdraw makes it unread in her Mine.
|
||||
await issuesSvc.markRead(companyId, withdrawnIssue.id, dotta, now());
|
||||
const withdrawnFor = await handledFor("issue_thread_interaction", withdrawn.id);
|
||||
const reason = "We already signed with Acme.";
|
||||
await interactions.withdrawInteraction({ id: withdrawnIssue.id, companyId }, withdrawn.id, { reason }, { userId: scott });
|
||||
for (const action of ["issue.thread_interaction_withdrawn", "attention.closed"]) {
|
||||
await logActivity(db, {
|
||||
companyId, actorType: "user", actorId: scott, action, entityType: "issue", entityId: withdrawnIssue.id, issueId: withdrawnIssue.id,
|
||||
details: action === "attention.closed"
|
||||
? { sourceKind: "issue_thread_interaction", sourceId: withdrawn.id, mode: "withdraw", reason, until: null, closedByUserId: scott, ...attentionHandledDetails(withdrawnFor, "withdrawn", { reason }) }
|
||||
: { interactionId: withdrawn.id, interactionKind: "ask_user_questions", interactionStatus: "cancelled", reason, ...attentionHandledDetails(withdrawnFor, "withdrawn", { reason }) },
|
||||
});
|
||||
}
|
||||
card("Receipts", "Dotta: \"Withdrawn by @Scott\" with the reason; task unread in her Mine");
|
||||
|
||||
const approvedRequest = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: dotta, payload: { title: "Approve the Q4 hiring plan", summary: "Dotta asked; Scott approved." } } as never) as { id: string };
|
||||
const approvedFor = await handledFor("approval", approvedRequest.id);
|
||||
await approvalSvc.approve(approvedRequest.id, scott, null);
|
||||
await logActivity(db, {
|
||||
companyId, actorType: "user", actorId: scott, action: "approval.approved", entityType: "approval", entityId: approvedRequest.id,
|
||||
details: { type: "request_board_approval", requestedByAgentId: null, linkedIssueIds: [], ...attentionHandledDetails(approvedFor, "approved") },
|
||||
});
|
||||
card("Receipts", "Dotta: \"Approved by @Scott\" (approval she requested)");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: One obligation (task 2d, Rule 11) — an approval with its in-review
|
||||
// task, and a blocked task with the failed run that caused it, each
|
||||
// shown as one card. A configured human review stage is an
|
||||
// independent quality gate, so that pair stays two cards.
|
||||
// =========================================================================
|
||||
{
|
||||
// The task is in review only because it waits on the approval: one card.
|
||||
const obIssue = await makeIssue({ title: "Obligation: approval + in-review task", status: "in_review", createdByUserId: scott, responsibleUserId: scott });
|
||||
const obApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Approve the in-review task", summary: "One obligation: the approval and the in-review task are the same ask." } } as never);
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: obIssue.id, approvalId: (obApproval as { id: string }).id });
|
||||
card("One obligation", "approval paired with its in-review task (one card: the approval, with the task as evidence)");
|
||||
}
|
||||
{
|
||||
// Scott is also the configured reviewer: the review verdict and the
|
||||
// approval are separate gates, so they stay two cards.
|
||||
const gateIssue = await makeIssue({ title: "Independent gate: human review + approval", status: "todo", createdByUserId: scott, responsibleUserId: scott });
|
||||
await issuesSvc.update(gateIssue.id, { status: "in_review", assigneeAgentId: null, assigneeUserId: scott, responsibleUserId: scott, executionState: reviewExecutionState(scott) } as never);
|
||||
const gateApproval = await approvalSvc.create(companyId, { type: "request_board_approval", status: "pending", requestedByUserId: scott, payload: { title: "Approve the reviewed task", summary: "A separate gate from the human review stage on the same task." } } as never);
|
||||
await db.insert(issueApprovals).values({ companyId, issueId: gateIssue.id, approvalId: (gateApproval as { id: string }).id });
|
||||
card("One obligation", "independent quality gate: a human review stage and an approval stay two cards");
|
||||
}
|
||||
{
|
||||
const blockedIssue = await makeIssue({ title: "Obligation: blocked task + failed run", status: "blocked", createdByUserId: scott, responsibleUserId: scott, assigneeAgentId: failingAgent!.id });
|
||||
// What exhausted retries leave on the task: blocked, with a board-owned next action.
|
||||
await db.update(issuesTable).set({
|
||||
unblockDescriptor: { owner: "board", action: "Automatic retries exhausted. Retry the run or reassign the task." },
|
||||
blockedTransitionAt: hoursAgo(4),
|
||||
}).where(eq(issuesTable.id, blockedIssue.id));
|
||||
const [brun] = await db.insert(heartbeatRuns).values({
|
||||
companyId, agentId: failingAgent!.id, scopeKind: "issue", issueId: blockedIssue.id, status: "failed",
|
||||
invocationSource: "on_demand", responsibleUserId: scott, error: "Run failed and blocked the task.",
|
||||
contextSnapshot: { issueId: blockedIssue.id }, startedAt: hoursAgo(5), finishedAt: hoursAgo(4),
|
||||
}).returning();
|
||||
await db.insert(heartbeatRunEvents).values({
|
||||
companyId, runId: brun!.id, agentId: failingAgent!.id, seq: 1, eventType: "lifecycle",
|
||||
message: "Bounded retry exhausted after 3 scheduled attempts; the task is blocked.", createdAt: hoursAgo(4),
|
||||
});
|
||||
card("One obligation", "blocked task paired with its failed run (one card: the run with Retry, with the blocked task as evidence)");
|
||||
}
|
||||
|
||||
// Keep the demo deterministic (plan section 7: "Agents in the demo company do
|
||||
// not run. The seed makes their state directly."). Pausing every non-errored
|
||||
// agent stops the heartbeat scheduler from starting a new run that would, for
|
||||
// example, supersede the seeded failed_run card.
|
||||
await db.update(agents).set({ status: "paused", updatedAt: now() })
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "error")));
|
||||
|
||||
return {
|
||||
companyId,
|
||||
companyName: company.name,
|
||||
prefix: company.issuePrefix,
|
||||
users: DEMO_USERS.map((u) => ({ name: u.name, email: u.email, role: u.role })),
|
||||
cardCount,
|
||||
defaultDecisionOwner: defaultDecisionOwnerId ? defaultDecisionOwnerKey : null,
|
||||
budgetOwner: budgetOwner.id ? budgetOwner.key : null,
|
||||
projectDecisionOwner: projectDecisionOwner.id ? projectDecisionOwner.key : null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- small helpers ---------------------------------------------------------
|
||||
|
||||
async function createProject(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
leadAgentId: string,
|
||||
overrides: { name?: string; description?: string; decisionOwnerUserId?: string | null } = {},
|
||||
): Promise<string> {
|
||||
const [project] = await db.insert(projects).values({
|
||||
companyId, name: "Needs You Demo", description: "Holds the demo issues.", status: "in_progress", leadAgentId, ...overrides,
|
||||
}).returning();
|
||||
return (project as { id: string }).id;
|
||||
}
|
||||
|
||||
async function backdateInteraction(db: Db, interactionId: string, when: Date) {
|
||||
await db.update(issueThreadInteractions).set({ createdAt: when, updatedAt: when }).where(eq(issueThreadInteractions.id, interactionId));
|
||||
}
|
||||
|
||||
async function makeOriginRun(db: Db, companyId: string, agentId: string, issueId: string, responsibleUserId: string): Promise<string> {
|
||||
const [run] = await db.insert(heartbeatRuns).values({
|
||||
companyId, agentId, scopeKind: "issue", issueId, status: "succeeded", invocationSource: "on_demand",
|
||||
responsibleUserId, contextSnapshot: { issueId }, startedAt: hoursAgo(1), finishedAt: hoursAgo(1),
|
||||
}).returning();
|
||||
return (run as { id: string }).id;
|
||||
}
|
||||
|
||||
async function boardActorFor(db: Db, companyId: string, userId: string) {
|
||||
const membership = await db
|
||||
.select({ companyId: companyMemberships.companyId, membershipRole: companyMemberships.membershipRole, status: companyMemberships.status })
|
||||
.from(companyMemberships)
|
||||
.where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active")))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return { type: "board", userId, companyIds: [companyId], memberships: membership ? [membership] : [], source: "session" } as never;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* scripts/verify-needs-you-checklist.ts
|
||||
*
|
||||
* PAP-749 final check: walks the plan's section 7 "things to try" checklist,
|
||||
* plus the task 1d escalation owners and the PAP-768 additions (P1 Everyone,
|
||||
* P2 personal history, P3 one budget-stop card), through the real HTTP API as each demo
|
||||
* user (Scott, Dotta, Riley). It signs in with the demo password, so it needs a
|
||||
* running server in `authenticated` mode whose database holds a FRESH run of
|
||||
* scripts/seed-needs-you-demo.ts (default seed options).
|
||||
*
|
||||
* It CHANGES the demo data (withdraw, transfer, claim, archive/restore,
|
||||
* withdraw all stale, keep a budget stop paused). Re-run the seed afterwards
|
||||
* to give a reviewer the untouched demo.
|
||||
*
|
||||
* BASE_URL=http://127.0.0.1:3200 \
|
||||
* pnpm --filter @paperclipai/server exec tsx ../scripts/verify-needs-you-checklist.ts
|
||||
*
|
||||
* What it cannot prove: anything a browser shows (the live update without a
|
||||
* reload, the confirmation dialogs, the classic page's layout). Those steps are
|
||||
* reported as API-level checks only.
|
||||
*/
|
||||
import type { AttentionFeed, AttentionItem, AttentionReceipt, DecisionHistoryItem } from "../packages/shared/src/index.js";
|
||||
|
||||
const BASE_URL = (process.env.BASE_URL ?? "http://127.0.0.1:3200").replace(/\/$/, "");
|
||||
const PASSWORD = process.env.SEED_DEMO_PASSWORD ?? "needs-you-demo-2026";
|
||||
const DEMO_COMPANY_NAME = "Needs You Demo";
|
||||
const USERS = {
|
||||
scott: "scott@needs-you-demo.test",
|
||||
dotta: "dotta@needs-you-demo.test",
|
||||
riley: "riley@needs-you-demo.test",
|
||||
} as const;
|
||||
type UserKey = keyof typeof USERS;
|
||||
|
||||
type Session = { key: UserKey; userId: string; cookie: string };
|
||||
type View = "mine" | "unassigned" | "company" | "archived" | "all";
|
||||
|
||||
const results: Array<{ step: string; ok: boolean; detail: string }> = [];
|
||||
function check(step: string, ok: boolean, detail: string) {
|
||||
results.push({ step, ok, detail });
|
||||
console.log(`${ok ? "PASS" : "FAIL"} ${step} — ${detail}`);
|
||||
}
|
||||
|
||||
async function http<T>(session: Session | null, method: string, path: string, body?: unknown): Promise<{ status: number; json: T }> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Origin: BASE_URL,
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...(session ? { Cookie: session.cookie } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: unknown = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { json = text; }
|
||||
return { status: res.status, json: json as T };
|
||||
}
|
||||
|
||||
async function signIn(key: UserKey): Promise<Session> {
|
||||
const res = await fetch(`${BASE_URL}/api/auth/sign-in/email`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json", Origin: BASE_URL },
|
||||
body: JSON.stringify({ email: USERS[key], password: PASSWORD }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`sign-in for ${key} failed: ${res.status} ${await res.text()}`);
|
||||
const cookie = res.headers.getSetCookie().map((value) => value.split(";")[0]).join("; ");
|
||||
const session = await http<{ user?: { id: string } }>({ key, userId: "", cookie }, "GET", "/api/auth/get-session");
|
||||
const userId = session.json?.user?.id;
|
||||
if (!userId) throw new Error(`no session user for ${key}`);
|
||||
return { key, userId, cookie };
|
||||
}
|
||||
|
||||
let companyId = "";
|
||||
async function feed(session: Session, view: View): Promise<AttentionFeed> {
|
||||
const res = await http<AttentionFeed>(session, "GET", `/api/companies/${companyId}/attention?all=true&view=${view}`);
|
||||
if (res.status !== 200) throw new Error(`feed ${view} for ${session.key}: ${res.status} ${JSON.stringify(res.json)}`);
|
||||
return res.json;
|
||||
}
|
||||
|
||||
/** The cards a page shows: obligation evidence folds into its lead (task 2d). */
|
||||
function leads(items: AttentionItem[]) {
|
||||
return items.filter((item) => !item.obligationLeadId || item.obligationLeadId === item.id);
|
||||
}
|
||||
const has = (items: AttentionItem[], id: string) => items.some((item) => item.id === id);
|
||||
const titleOf = (item: AttentionItem | undefined) => item?.subject.title ?? "(none)";
|
||||
|
||||
async function close(session: Session, item: AttentionItem, body: Record<string, unknown>) {
|
||||
return http<unknown>(session, "POST", `/api/companies/${companyId}/attention/${item.sourceKind}/${item.subject.id}/close`, body);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[verify] ${BASE_URL}`);
|
||||
const scott = await signIn("scott");
|
||||
const dotta = await signIn("dotta");
|
||||
const riley = await signIn("riley");
|
||||
const name = (userId: string | null | undefined) =>
|
||||
[scott, dotta, riley].find((session) => session.userId === userId)?.key ?? userId ?? "nobody";
|
||||
|
||||
const companies = await http<Array<{ id: string; name: string }>>(scott, "GET", "/api/companies");
|
||||
const company = companies.json.find((entry) => entry.name === DEMO_COMPANY_NAME);
|
||||
if (!company) throw new Error("demo company not found; run the seed first");
|
||||
companyId = company.id;
|
||||
console.log(`[verify] company ${companyId}`);
|
||||
|
||||
// 1. Each For you list is different, and the badge agrees with the list.
|
||||
const mine = { scott: await feed(scott, "mine"), dotta: await feed(dotta, "mine"), riley: await feed(riley, "mine") };
|
||||
const scottIds = new Set(mine.scott.items.map((item) => item.id));
|
||||
const overlap = mine.dotta.items.filter((item) => scottIds.has(item.id));
|
||||
check("1 For you differs", mine.scott.items.length > 0 && mine.dotta.items.length > 0 && overlap.length === 0,
|
||||
`Scott ${mine.scott.items.length} cards, Dotta ${mine.dotta.items.length}, shared ${overlap.length}; Riley ${mine.riley.items.length}`);
|
||||
for (const [key, data] of Object.entries(mine)) {
|
||||
const shown = leads(data.items).length;
|
||||
check(`1 badge = list (${key})`, data.deskBadgeCount === data.viewCounts.mine && data.viewCounts.mine === shown,
|
||||
`badge ${data.deskBadgeCount}, For you tab ${data.viewCounts.mine}, cards shown ${shown}`);
|
||||
}
|
||||
check("1 every For you card is the viewer's", (["scott", "dotta", "riley"] as const).every((key) =>
|
||||
mine[key].items.every((item) => item.audience.userId === { scott, dotta, riley }[key].userId)), "audience.userId matches the viewer");
|
||||
|
||||
// Task 1d: escalation owners.
|
||||
const hardStop = mine.scott.items.find((item) => item.sourceKind === "budget_alert");
|
||||
check("1d budget owner card", hardStop?.audience.reason === "budget_owner" && hardStop.audience.alsoInCompany === true
|
||||
&& hardStop.decisionVerbs.map((verb) => verb.label).join("/") === "Raise limit/Keep paused",
|
||||
`Scott For you: "${titleOf(hardStop)}" (${hardStop?.audience.reason}; ${hardStop?.decisionVerbs.map((verb) => verb.label).join(" / ")})`);
|
||||
const companyViews = { scott: await feed(scott, "company"), dotta: await feed(dotta, "company"), riley: await feed(riley, "company") };
|
||||
check("1d Company still shows the hard stop", Boolean(hardStop) && Object.values(companyViews).every((data) => has(data.items, hardStop!.id)),
|
||||
"in Company for Scott, Dotta and Riley (as For @scott for the other two)");
|
||||
check("1d hard stop only in the owner's For you", Boolean(hardStop) && !has(mine.dotta.items, hardStop!.id) && !has(mine.riley.items, hardStop!.id),
|
||||
"not in Dotta's or Riley's For you");
|
||||
const warning = companyViews.scott.items.find((item) => item.sourceKind === "budget_alert" && item.subject.metadata?.thresholdType === "soft");
|
||||
check("1d budget warning stays Company only", warning?.audience.kind === "company", `"${titleOf(warning)}" audience ${warning?.audience.kind}`);
|
||||
// PAP-768 part 3: the hard stop and its override approval are one card. The
|
||||
// stop leads; the approval is its evidence and is no longer an ownerless
|
||||
// card in Unassigned.
|
||||
const overrideApprovalId = (hardStop?.subject.metadata as { approvalId?: string | null } | null)?.approvalId ?? null;
|
||||
const overrideEvidence = mine.scott.items.find((item) => item.sourceKind === "approval" && item.subject.id === overrideApprovalId);
|
||||
check("P3 one card for the budget stop", Boolean(hardStop && overrideEvidence) && overrideEvidence!.obligationLeadId === hardStop!.id
|
||||
&& has(leads(mine.scott.items), hardStop!.id) && !has(leads(mine.scott.items), overrideEvidence!.id),
|
||||
`Scott For you: "${titleOf(hardStop)}" leads; "${titleOf(overrideEvidence)}" folded in (lead ${overrideEvidence?.obligationLeadId === hardStop?.id ? "the stop" : overrideEvidence?.obligationLeadId ?? "none"})`);
|
||||
const pools = { scott: await feed(scott, "unassigned"), dotta: await feed(dotta, "unassigned"), riley: await feed(riley, "unassigned") };
|
||||
check("P3 override approval not in Unassigned", Boolean(overrideApprovalId)
|
||||
&& Object.values(pools).every((data) => !data.items.some((item) => item.subject.id === overrideApprovalId)),
|
||||
`not in Scott's, Dotta's or Riley's Unassigned (${Object.values(pools).map((data) => data.viewCounts.unassigned).join("/")} cards)`);
|
||||
check("P3 the stop counts once", Object.values(companyViews).every((data) => data.items.filter((item) => item.subject.id === overrideApprovalId)
|
||||
.every((item) => item.obligationLeadId === hardStop?.id)) && mine.scott.viewCounts.mine === leads(mine.scott.items).length,
|
||||
"in Company the approval is evidence of the stop; For you counts the stop once");
|
||||
|
||||
const projectCard = mine.dotta.items.find((item) => item.audience.reason === "project_owner");
|
||||
check("1d project decision owner", Boolean(projectCard), `Dotta For you: "${titleOf(projectCard)}" (project_owner)`);
|
||||
|
||||
// PAP-768 part 1: Everyone (view=all) for board members, with its own count.
|
||||
const scottAll = await feed(scott, "all");
|
||||
const access = await http<{ memberships?: Array<{ companyId: string; membershipRole: string | null; status: string }> }>(scott, "GET", "/api/cli-auth/me");
|
||||
const scottMembership = access.json?.memberships?.find((entry) => entry.companyId === companyId && entry.status === "active");
|
||||
check("P1 Scott is a board member who sees Everyone", Boolean(scottMembership?.membershipRole && scottMembership.membershipRole !== "viewer"),
|
||||
`role ${scottMembership?.membershipRole ?? "none"}`);
|
||||
check("P1 Everyone count is its own, not the badge", scottAll.viewCounts.all === leads(scottAll.items).length
|
||||
&& scottAll.deskBadgeCount === scottAll.viewCounts.mine && scottAll.viewCounts.all > scottAll.viewCounts.mine,
|
||||
`Everyone tab ${scottAll.viewCounts.all}, cards ${leads(scottAll.items).length}, badge ${scottAll.deskBadgeCount} (For you ${scottAll.viewCounts.mine})`);
|
||||
const othersInEveryone = leads(scottAll.items).filter((item) => item.audience.kind === "user" && item.audience.userId && item.audience.userId !== scott.userId);
|
||||
check("P1 Everyone shows other people's cards", othersInEveryone.some((item) => item.audience.userId === dotta.userId),
|
||||
`${othersInEveryone.length} cards for others (${[...new Set(othersInEveryone.map((item) => name(item.audience.userId)))].join(", ")}); the UI shows each as "For @name"`);
|
||||
|
||||
// 2. As Scott, withdraw a card for Dotta from Everyone; Dotta gets the
|
||||
// receipt. Cards addressed to Dotta stay out of Scott's feed (the
|
||||
// pre-PAP-749 addressee filter), so these are her instigator and
|
||||
// project-owner cards; the project-owner card goes last so the task 1d demo
|
||||
// card survives step 2.
|
||||
const dottaCards = scottAll.items
|
||||
.filter((item) => item.audience.userId === dotta.userId
|
||||
&& item.sourceKind === "issue_thread_interaction" && item.closeModes.includes("withdraw") && !item.stale)
|
||||
.sort((left, right) => Number(left.audience.reason === "project_owner") - Number(right.audience.reason === "project_owner"));
|
||||
const toWithdraw = dottaCards[0];
|
||||
if (!toWithdraw) throw new Error("no withdrawable card for Dotta");
|
||||
const withdraw = await close(scott, toWithdraw, { mode: "withdraw", reason: "Out of date (checklist step 2)" });
|
||||
const dottaAfterWithdraw = await feed(dotta, "mine");
|
||||
const withdrawReceipt = (dottaAfterWithdraw.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === toWithdraw.subject.id);
|
||||
check("2 withdraw for Dotta (from Everyone)", withdraw.status === 200 && !has(dottaAfterWithdraw.items, toWithdraw.id)
|
||||
&& withdrawReceipt?.outcome === "withdrawn" && withdrawReceipt.actorUserId === scott.userId,
|
||||
`"${titleOf(toWithdraw)}" -> ${withdraw.status}; Dotta receipt: ${withdrawReceipt ? `${withdrawReceipt.outcome} by ${name(withdrawReceipt.actorUserId)} (reason: ${withdrawReceipt.reason})` : "none"}`);
|
||||
|
||||
// 3. As Scott, transfer a card to Riley; it shows in Riley's For you.
|
||||
const toTransfer = dottaCards.find((item) => item.id !== toWithdraw.id);
|
||||
if (!toTransfer) throw new Error("no second card for Dotta to transfer");
|
||||
const transfer = await http<unknown>(scott, "POST", `/api/companies/${companyId}/attention/${toTransfer.sourceKind}/${toTransfer.subject.id}/transfer`, {
|
||||
userId: riley.userId, reason: "Riley owns this now (checklist step 3)",
|
||||
});
|
||||
const rileyAfter = await feed(riley, "mine");
|
||||
const transferred = rileyAfter.items.find((item) => item.subject.id === toTransfer.subject.id);
|
||||
const dottaAfterTransfer = await feed(dotta, "mine");
|
||||
const transferReceipt = (dottaAfterTransfer.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === toTransfer.subject.id);
|
||||
check("3 transfer to Riley", transfer.status === 200 && transferred?.audience.transferredByUserId === scott.userId
|
||||
&& !has(dottaAfterTransfer.items, toTransfer.id) && transferReceipt?.outcome === "transferred",
|
||||
`"${titleOf(toTransfer)}" -> ${transfer.status}; Riley For you: ${transferred ? `yes, "Transferred by @${name(transferred.audience.transferredByUserId)}"` : "no"}; Dotta receipt: ${transferReceipt?.outcome ?? "none"}`);
|
||||
|
||||
// 4. As Scott, claim an Unassigned card; it leaves Dotta's Unassigned.
|
||||
const scottPool = await feed(scott, "unassigned");
|
||||
const toClaim = scottPool.items.find((item) => item.sourceKind === "issue_thread_interaction");
|
||||
if (!toClaim) throw new Error("no claimable Unassigned card");
|
||||
const dottaPoolBefore = await feed(dotta, "unassigned");
|
||||
const claim = await http<unknown>(scott, "POST", `/api/companies/${companyId}/attention/${toClaim.sourceKind}/${toClaim.subject.id}/claim`, {});
|
||||
const dottaPoolAfter = await feed(dotta, "unassigned");
|
||||
const scottMineAfterClaim = await feed(scott, "mine");
|
||||
check("4 claim from Unassigned", claim.status === 200 && has(dottaPoolBefore.items, toClaim.id) && !has(dottaPoolAfter.items, toClaim.id)
|
||||
&& has(scottMineAfterClaim.items, toClaim.id),
|
||||
`"${titleOf(toClaim)}" -> ${claim.status}; Dotta Unassigned ${dottaPoolBefore.viewCounts.unassigned} -> ${dottaPoolAfter.viewCounts.unassigned}; now in Scott's For you`);
|
||||
|
||||
// 5. Archive a card; it shows in Archived; Restore works; Dotta still sees it.
|
||||
const scottCompany = await feed(scott, "company");
|
||||
const toArchive = scottCompany.items.find((item) => item.sourceKind === "agent_error_alert") ?? scottCompany.items[0];
|
||||
if (!toArchive) throw new Error("no Company card to archive");
|
||||
const archive = await close(scott, toArchive, { mode: "archive", fingerprint: toArchive.changeFingerprint });
|
||||
const scottArchived = await feed(scott, "archived");
|
||||
const scottCompanyAfter = await feed(scott, "company");
|
||||
const dottaCompanyAfter = await feed(dotta, "company");
|
||||
check("5 archive for me", archive.status === 200 && has(scottArchived.items, toArchive.id) && !has(scottCompanyAfter.items, toArchive.id)
|
||||
&& has(dottaCompanyAfter.items, toArchive.id),
|
||||
`"${titleOf(toArchive)}" -> ${archive.status}; in Scott's Archived (tab ${scottArchived.viewCounts.archived}); Dotta still sees it`);
|
||||
const restore = await http<unknown>(scott, "DELETE", `/api/companies/${companyId}/inbox-dismissals/${encodeURIComponent(toArchive.dismissalKey)}`);
|
||||
const scottCompanyRestored = await feed(scott, "company");
|
||||
const scottArchivedRestored = await feed(scott, "archived");
|
||||
check("5 restore", restore.status >= 200 && restore.status < 300 && has(scottCompanyRestored.items, toArchive.id) && !has(scottArchivedRestored.items, toArchive.id),
|
||||
`restore -> ${restore.status}; back in Scott's Company view`);
|
||||
|
||||
// 6. Withdraw all stale: only non-required cards with a real withdraw close.
|
||||
const scottMine = await feed(scott, "mine");
|
||||
const stale = leads(scottMine.items).filter((item) => item.stale);
|
||||
const withdrawable = stale.filter((item) => item.closeModes.includes("withdraw") && !item.required);
|
||||
const kept = stale.filter((item) => !withdrawable.includes(item));
|
||||
const statuses: number[] = [];
|
||||
for (const item of withdrawable) statuses.push((await close(scott, item, { mode: "withdraw", reason: "Withdraw all stale (checklist step 6)" })).status);
|
||||
const scottMineAfterStale = await feed(scott, "mine");
|
||||
check("6 withdraw all stale", stale.length > 0 && statuses.every((status) => status === 200)
|
||||
&& withdrawable.every((item) => !has(scottMineAfterStale.items, item.id)) && kept.every((item) => has(scottMineAfterStale.items, item.id)),
|
||||
`Stale (${stale.length}): withdrew ${withdrawable.length} [${statuses.join(",")}], kept ${kept.length} (${kept.map((item) => item.required ? "required" : "no withdraw").join(", ") || "none"})`);
|
||||
|
||||
// 7. The stale approval is in Company with "Waiting 30+ days", still pending.
|
||||
const companyNow = await feed(dotta, "company");
|
||||
const staleApproval = companyNow.items.find((item) => item.sourceKind === "approval" && item.stale?.reason === "aged");
|
||||
check("7 stale approval escalated, not withdrawn", staleApproval?.subject.status === "pending" && staleApproval.required === true,
|
||||
`"${titleOf(staleApproval)}" in Company, stale ${staleApproval?.stale?.reason}, status ${staleApproval?.subject.status}`);
|
||||
|
||||
// 8. Decision history filters.
|
||||
type History = { items: Array<Pick<DecisionHistoryItem, "sourceKind" | "sourceId" | "label" | "reason" | "action" | "resolvedByUserId" | "handledForUserId">> };
|
||||
const history = async (session: Session, query: string) =>
|
||||
(await http<History>(session, "GET", `/api/companies/${companyId}/attention/history?limit=100&${query}`)).json.items;
|
||||
const withdrawn = await history(scott, "outcome=withdrawn");
|
||||
const expired = await history(scott, "outcome=expired");
|
||||
const closedWithTask = await history(scott, "outcome=issue_closed");
|
||||
const handledForDotta = await history(dotta, "handledForMe=true");
|
||||
check("8 history Withdrawn", withdrawn.some((item) => item.sourceId === toWithdraw.subject.id), `${withdrawn.length} entries, includes step 2`);
|
||||
check("8 history Expired", expired.length > 0, `${expired.length} entries`);
|
||||
check("8 history Closed with task", closedWithTask.length > 0, `${closedWithTask.length} entries`);
|
||||
check("8 history Handled for me (Dotta)", handledForDotta.some((item) => item.sourceId === toWithdraw.subject.id), `${handledForDotta.length} entries, includes step 2`);
|
||||
|
||||
// PAP-768 part 2: history opens on "Handled by me or for me" (scope=mine,
|
||||
// what both pages ask for first); Everyone (scope=all) is every person's.
|
||||
// The board's case: Scott answers the confirmation on "Scott: task you
|
||||
// created"; it must stay out of Dotta's default history.
|
||||
const ownTaskCard = (await feed(scott, "mine")).items.find((item) => item.sourceKind === "issue_thread_interaction"
|
||||
&& item.relatedIssue?.title === "Scott: task you created");
|
||||
if (!ownTaskCard?.relatedIssue) throw new Error("no confirmation on \"Scott: task you created\" in Scott's For you");
|
||||
const accept = await http<unknown>(scott, "POST", `/api/issues/${ownTaskCard.relatedIssue.id}/interactions/${ownTaskCard.subject.id}/accept`, {});
|
||||
const ownTaskRow = (items: History["items"]) => items.some((item) => item.sourceId === ownTaskCard.subject.id);
|
||||
const scottMineHistory = await history(scott, "scope=mine");
|
||||
const dottaMineHistory = await history(dotta, "scope=mine");
|
||||
const dottaEveryoneHistory = await history(dotta, "scope=all");
|
||||
check("P2 Scott's own answer is in his history", accept.status === 200 && ownTaskRow(scottMineHistory),
|
||||
`accept "${ownTaskCard.relatedIssue.identifier} · ${ownTaskCard.relatedIssue.title}" -> ${accept.status}; in Scott's Handled by me or for me`);
|
||||
check("P2 Dotta's default history leaves it out", !ownTaskRow(dottaMineHistory),
|
||||
`Dotta Handled by me or for me: ${dottaMineHistory.length} entries, none on ${ownTaskCard.relatedIssue.identifier}`);
|
||||
check("P2 Dotta's Everyone history shows it", ownTaskRow(dottaEveryoneHistory),
|
||||
`Dotta Everyone: ${dottaEveryoneHistory.length} entries, includes ${ownTaskCard.relatedIssue.identifier} · Scott: task you created`);
|
||||
check("P2 Dotta's history is hers", dottaMineHistory.length > 0 && dottaMineHistory.length < dottaEveryoneHistory.length,
|
||||
`${dottaMineHistory.length} of ${dottaEveryoneHistory.length} entries`);
|
||||
const dottaMineWithdrawn = await history(dotta, "scope=mine&outcome=withdrawn");
|
||||
const dottaMineHandled = await history(dotta, "scope=mine&handledForMe=true");
|
||||
const scottMineExpired = await history(scott, "scope=mine&outcome=expired");
|
||||
check("P2 outcome filters work inside each scope", dottaMineWithdrawn.some((item) => item.sourceId === toWithdraw.subject.id)
|
||||
&& dottaMineHandled.some((item) => item.sourceId === toWithdraw.subject.id) && scottMineExpired.length > 0,
|
||||
`Dotta mine + Withdrawn ${dottaMineWithdrawn.length} (step 2 withdraw for her), mine + Handled for me ${dottaMineHandled.length}; Scott mine + Expired ${scottMineExpired.length}`);
|
||||
|
||||
// 9. The classic Decisions page reads the same per-person feed (`all=true&view=`).
|
||||
const classic = await feed(dotta, "mine");
|
||||
check("9 classic page feed (API only)", classic.deskBadgeCount === classic.viewCounts.mine,
|
||||
"same endpoint and query as Needs You; its layout needs a browser");
|
||||
|
||||
// Task 1d: Dotta keeps Scott's hard stop paused; Scott gets the receipt.
|
||||
if (hardStop) {
|
||||
const resolve = await http<unknown>(dotta, "POST", `/api/companies/${companyId}/budget-incidents/${hardStop.subject.id}/resolve`, { action: "keep_paused" });
|
||||
const scottAfterBudget = await feed(scott, "mine");
|
||||
const budgetReceipt = (scottAfterBudget.receipts ?? []).find((receipt: AttentionReceipt) => receipt.sourceId === hardStop.subject.id);
|
||||
const companyAfterBudget = await feed(riley, "company");
|
||||
check("1d receipt for the budget owner", resolve.status === 200 && budgetReceipt?.actorUserId === dotta.userId
|
||||
&& budgetReceipt.outcome === "kept_paused"
|
||||
&& !has(scottAfterBudget.items, hardStop.id) && !has(companyAfterBudget.items, hardStop.id),
|
||||
`Keep paused by Dotta -> ${resolve.status}; Scott receipt: ${budgetReceipt ? `${budgetReceipt.outcome} by ${name(budgetReceipt.actorUserId)} -> ${budgetReceipt.href}` : "none"}`);
|
||||
// PAP-767: the same action is in Scott's "Handled for me" history as the
|
||||
// hard stop's approval, kept paused by Dotta for Scott.
|
||||
const approvalId = overrideApprovalId;
|
||||
const overrideAfter = approvalId ? await http<{ status?: string; decidedByUserId?: string | null }>(scott, "GET", `/api/approvals/${approvalId}`) : null;
|
||||
check("P3 Keep paused rejects the folded approval", overrideAfter?.json?.status === "rejected" && overrideAfter.json.decidedByUserId === dotta.userId,
|
||||
`approval ${overrideAfter?.json?.status ?? "missing"} by ${name(overrideAfter?.json?.decidedByUserId)}`);
|
||||
const scottHandled = await history(scott, "handledForMe=true");
|
||||
const keptPaused = scottHandled.find((item) => item.sourceKind === "approval" && item.sourceId === approvalId);
|
||||
check("1d history: kept paused by Dotta for Scott", Boolean(approvalId) && keptPaused?.action?.outcome === "kept_paused"
|
||||
&& keptPaused.resolvedByUserId === dotta.userId && keptPaused.handledForUserId === scott.userId,
|
||||
keptPaused ? `${keptPaused.action?.outcome} by ${name(keptPaused.resolvedByUserId)} for ${name(keptPaused.handledForUserId)}` : `not in Scott's Handled for me (${scottHandled.length} entries)`);
|
||||
// The same row in both people's own history (PAP-768 parts 2 and 3).
|
||||
const inOwnHistory = async (session: Session) => (await history(session, "scope=mine")).find((item) => item.sourceId === approvalId);
|
||||
const [scottOwn, dottaOwn, rileyOwn] = [await inOwnHistory(scott), await inOwnHistory(dotta), await inOwnHistory(riley)];
|
||||
check("P3 history: Kept paused by @Dotta · for @Scott in both histories", scottOwn?.action?.outcome === "kept_paused" && dottaOwn?.handledForUserId === scott.userId
|
||||
&& !rileyOwn,
|
||||
`Scott ${scottOwn ? "yes" : "no"}, Dotta ${dottaOwn ? "yes" : "no"}, Riley ${rileyOwn ? "yes" : "no"}`);
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => !result.ok);
|
||||
console.log(`\n[verify] ${results.length - failed.length}/${results.length} checks passed`);
|
||||
process.exit(failed.length > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in new issue
Block a user