@teamscala/service-boot 0.23.317 → 0.23.319
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +526 -526
- package/src/agreement-gate.ts +12 -0
- package/src/auth-gate.test.ts +70 -0
- package/src/auth-gate.ts +68 -6
- package/src/build-client-assets.test.ts +1 -1
- package/src/build-client-assets.ts +1 -1
- package/src/discover-blocks.ts +1 -1
- package/src/fn-routers/index.ts +2 -0
- package/src/fn-routers/task-sync-assign.test.ts +123 -8
- package/src/fn-routers/task-sync-assign.ts +105 -18
- package/src/fn-routers/task-sync-bind.test.ts +18 -18
- package/src/fn-routers/task-sync-brief.test.ts +65 -22
- package/src/fn-routers/task-sync-brief.ts +77 -23
- package/src/fn-routers/task-sync-close.test.ts +44 -0
- package/src/fn-routers/task-sync-close.ts +82 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
- package/src/fn-routers/task-sync-hook-lifecycle.test.ts +204 -0
- package/src/fn-routers/task-sync-lane.test.ts +36 -0
- package/src/fn-routers/task-sync-projection.test.ts +26 -0
- package/src/fn-routers/task-sync-projection.ts +51 -0
- package/src/fn-routers/task-sync-rename.test.ts +53 -0
- package/src/fn-routers/task-sync-rename.ts +111 -0
- package/src/fn-routers/task-sync-router.test.ts +14 -2
- package/src/handlers/singleton-tools-currency-watch.ts +114 -11
- package/src/handlers/sweep-host-cache.test.ts +133 -0
- package/src/handlers/sweep-host-cache.ts +143 -26
- package/src/install-logging.ts +3 -0
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-api-routes.ts +8 -2
- package/src/register-page-routes.ts +0 -3
- package/src/resolve-brand-favicon.ts +1 -1
- package/src/resource-not-found.ts +10 -0
- package/src/serve/stamp-transport-identity.test.ts +24 -0
- package/src/serve/stamp-transport-identity.ts +20 -7
- package/src/two-phase-boot.ts +85 -17
- package/src/two-phase-health-gate-client-only.test.ts +77 -0
- package/src/two-phase-health-gate.test.ts +13 -2
- package/src/agreement-gate.d.ts +0 -19
- package/src/asset-cache-key.d.ts +0 -11
- package/src/asset-optimize.d.ts +0 -17
- package/src/asset-proxy.d.ts +0 -11
- package/src/asset-social-card.d.ts +0 -17
- package/src/attach-agreement-magic.d.ts +0 -20
- package/src/attach-portal-members.d.ts +0 -23
- package/src/attach-proposal-accept.d.ts +0 -26
- package/src/attach-proposal-magic.d.ts +0 -18
- package/src/attach-transfers-upload.d.ts +0 -27
- package/src/auth-gate.d.ts +0 -28
- package/src/bind-page-handler.d.ts +0 -18
- package/src/build-client-assets/input-hashing.d.ts +0 -25
- package/src/build-client-assets/source-candidates.d.ts +0 -27
- package/src/build-client-assets.d.ts +0 -50
- package/src/compose-page-title.d.ts +0 -41
- package/src/content-gate/access.d.ts +0 -15
- package/src/content-gate/config-types.d.ts +0 -99
- package/src/content-gate/gate.d.ts +0 -13
- package/src/content-gate/parse-cookie.d.ts +0 -9
- package/src/content-gate/token.d.ts +0 -48
- package/src/content-page-floor.d.ts +0 -39
- package/src/discover-blocks.d.ts +0 -28
- package/src/expand-roster.d.ts +0 -34
- package/src/fn-routers/agreement-completion-email.d.ts +0 -14
- package/src/fn-routers/agreement-send-router.d.ts +0 -82
- package/src/fn-routers/ai-health-router.d.ts +0 -35
- package/src/fn-routers/auth-router.d.ts +0 -47
- package/src/fn-routers/cache-admin-router.d.ts +0 -57
- package/src/fn-routers/chat-session-router.d.ts +0 -219
- package/src/fn-routers/email-defaults.d.ts +0 -1
- package/src/fn-routers/email-link-ttl.d.ts +0 -1
- package/src/fn-routers/index.d.ts +0 -18
- package/src/fn-routers/panel-data-router.d.ts +0 -19
- package/src/fn-routers/persist-chat-messages.d.ts +0 -36
- package/src/fn-routers/plans-router.d.ts +0 -25
- package/src/fn-routers/proposal-send-router.d.ts +0 -58
- package/src/fn-routers/public-agreement-router.d.ts +0 -289
- package/src/fn-routers/public-assessment-router.d.ts +0 -68
- package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
- package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
- package/src/fn-routers/task-sync-assign.d.ts +0 -276
- package/src/fn-routers/task-sync-brief.d.ts +0 -69
- package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
- package/src/fn-routers/task-sync-dispatch.ts +0 -124
- package/src/fn-routers/task-sync-lane.d.ts +0 -28
- package/src/fn-routers/task-sync-lane.ts +0 -91
- package/src/fn-routers/task-sync-projection.d.ts +0 -121
- package/src/fn-routers/task-sync-router.d.ts +0 -375
- package/src/fn-routers/task-sync-router.ts +0 -1011
- package/src/fn-routers/tasks-router.d.ts +0 -25
- package/src/fn-routers/user-preferences-router.d.ts +0 -77
- package/src/fn-routers/work-items-router.d.ts +0 -102
- package/src/gate-ttls.d.ts +0 -6
- package/src/gpt-gate.d.ts +0 -25
- package/src/handlers/bun-stable-watch.d.ts +0 -1
- package/src/handlers/pane-outbox-drain.d.ts +0 -1
- package/src/handlers/projects-digest.d.ts +0 -26
- package/src/handlers/prune-stale-data.d.ts +0 -30
- package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
- package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
- package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
- package/src/handlers/sweep-event-log.d.ts +0 -1
- package/src/handlers/sweep-host-cache.d.ts +0 -1
- package/src/handlers/upstream-issue-sync.d.ts +0 -1
- package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
- package/src/handlers/whatsapp-liveness.d.ts +0 -4
- package/src/hono-error-handler.d.ts +0 -25
- package/src/install-harness.d.ts +0 -45
- package/src/install-logging.d.ts +0 -34
- package/src/load-service-install-row.d.ts +0 -24
- package/src/load-service-prisma-client.d.ts +0 -18
- package/src/mount-orpc-router.d.ts +0 -15
- package/src/mount-server-packages.d.ts +0 -29
- package/src/mount.d.ts +0 -9
- package/src/mutable-app-ref.d.ts +0 -44
- package/src/page-route-shell-types.d.ts +0 -12
- package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
- package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
- package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
- package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
- package/src/page-server-orpc/server-client.d.ts +0 -16
- package/src/page-server-orpc/templates-router.d.ts +0 -34
- package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
- package/src/pg-boss-handler-registry.d.ts +0 -27
- package/src/pg-boss-mount.d.ts +0 -42
- package/src/proposal-authorized-emails.d.ts +0 -28
- package/src/proposal-chat-inject.d.ts +0 -17
- package/src/proposal-gate.d.ts +0 -18
- package/src/read-html-template.d.ts +0 -38
- package/src/read-viewport-from-request.d.ts +0 -18
- package/src/readiness-gate.d.ts +0 -16
- package/src/register-api-routes.d.ts +0 -18
- package/src/register-crash-handlers.d.ts +0 -15
- package/src/register-page-routes.d.ts +0 -27
- package/src/register-page-server-client.d.ts +0 -11
- package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
- package/src/register-registry-page-route/graph-cache.d.ts +0 -30
- package/src/register-registry-page-route/meta-title.d.ts +0 -2
- package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
- package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
- package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
- package/src/register-registry-page-route.d.ts +0 -27
- package/src/register-static-assets.d.ts +0 -13
- package/src/register-ws-routes.d.ts +0 -28
- package/src/resolve-app-base-url.d.ts +0 -13
- package/src/resolve-brand-favicon.d.ts +0 -28
- package/src/resolve-from-loader-config.d.ts +0 -32
- package/src/resolve-service-context.d.ts +0 -17
- package/src/resolve-service-port.d.ts +0 -75
- package/src/resolve-theme-mode.d.ts +0 -42
- package/src/resolvers/fetch-org-brand.d.ts +0 -2
- package/src/resolvers/portal-route-paths.d.ts +0 -39
- package/src/resolvers/website-by-host-map.d.ts +0 -12
- package/src/resource-chat-inject.d.ts +0 -46
- package/src/resource-not-found.d.ts +0 -60
- package/src/run-boot-hook-list.d.ts +0 -19
- package/src/run-boot-hooks.d.ts +0 -31
- package/src/scala-tools-currency-watchdog.d.ts +0 -10
- package/src/security-headers.d.ts +0 -12
- package/src/serve/serve.d.ts +0 -46
- package/src/serve/stamp-transport-identity.d.ts +0 -32
- package/src/server-error-page.d.ts +0 -29
- package/src/start-scheduled-jobs.d.ts +0 -18
- package/src/two-phase-boot.d.ts +0 -136
- package/src/types.d.ts +0 -46
- package/src/ui-service.d.ts +0 -46
- package/src/ws-boot-self-test.d.ts +0 -19
|
@@ -21,7 +21,7 @@ mock.module("@teamscala/db/registry/platform", () => ({
|
|
|
21
21
|
getPlatformOrgId: () => "platform-org-test",
|
|
22
22
|
}));
|
|
23
23
|
|
|
24
|
-
const { resolveBindOrg, discardApplied
|
|
24
|
+
const { resolveBindOrg, discardApplied } = await import(
|
|
25
25
|
"./task-sync-router.ts"
|
|
26
26
|
);
|
|
27
27
|
|
|
@@ -86,25 +86,25 @@ describe("lane task binding is deterministic and race-proof (source contract)",
|
|
|
86
86
|
expect(text).toContain("const winner = await resolveLaneTask(prisma, session)");
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
test("bindLane
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
test("bindLane leaves runtime identity to cli_sessions", async () => {
|
|
90
|
+
const text = await src.text();
|
|
91
|
+
const bindLane = text.slice(text.indexOf("bindLane:"), text.indexOf("upsertFromTaskList:"));
|
|
92
|
+
expect(bindLane).toContain("Runtime identity is owned by cli_sessions");
|
|
93
|
+
expect(bindLane).not.toContain("tracked_task_id");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("task events resolve pane identity through cli_sessions", async () => {
|
|
97
|
+
const text = await src.text();
|
|
98
|
+
const resolver = text.slice(text.indexOf("async function resolveSessionFromPane"), text.indexOf("/** Resolve the task"));
|
|
99
|
+
expect(resolver).toContain("prisma.cli_sessions.findFirst");
|
|
100
|
+
expect(resolver).toContain('path: ["pane_id"]');
|
|
95
101
|
});
|
|
96
102
|
|
|
97
|
-
test("
|
|
103
|
+
test("readTaskList returns the checklist with evidence-derived progress", async () => {
|
|
98
104
|
const text = await src.text();
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// (P2002 -> the lane-event 500 + the lost run-link join). Release-then-claim
|
|
104
|
-
// steals it: only this lane's untracked run ends up with it.
|
|
105
|
-
expect(text).toContain("STEAL before CLAIM");
|
|
106
|
-
expect(text).toContain(
|
|
107
|
-
"tracked_task_id: taskId, state: { not: \"closed\" }",
|
|
108
|
-
);
|
|
105
|
+
const reader = text.slice(text.indexOf("readTaskList:"), text.indexOf("/**\n\t * DB→AGENT self-brief"));
|
|
106
|
+
expect(reader).toContain("deriveEvidenceProgress(rows, evidence)");
|
|
107
|
+
expect(reader).toContain("subtasks");
|
|
108
|
+
expect(reader).not.toContain("percentage from");
|
|
109
109
|
});
|
|
110
110
|
});
|
|
@@ -5,56 +5,84 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Unit tests for the self-brief leg: shapeBriefResponse (the pure
|
|
7
7
|
* no-task/no-comment ⇒ nulls decision) + source-contract assertions that
|
|
8
|
-
*
|
|
8
|
+
* readFirstTaskComment reads the FIRST launch comment and the handler resolves by
|
|
9
9
|
* session. Covers `what-must-have-a-test` for the new procedure.
|
|
10
10
|
*/
|
|
11
11
|
import { describe, it, expect } from "bun:test";
|
|
12
|
-
import { shapeBriefResponse,
|
|
12
|
+
import { shapeBriefResponse, readFirstTaskComment } from "./task-sync-brief.ts";
|
|
13
13
|
|
|
14
14
|
describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
15
|
-
it("
|
|
16
|
-
// A lane with no
|
|
17
|
-
|
|
15
|
+
it("classifies a missing task as unbound (work_item_id null)", () => {
|
|
16
|
+
// A lane with no resolvable task has no brief — null, never an empty fake.
|
|
17
|
+
// status:"unbound" is the signal a non-fleet lane no-ops on and a fleet
|
|
18
|
+
// lane (which resolves by work_item_id, so cannot reach this) never sees.
|
|
19
|
+
expect(shapeBriefResponse(null, { id: "c-0", content: "x", created_at: new Date() })).toEqual({
|
|
18
20
|
work_item_id: null,
|
|
21
|
+
comment_id: null,
|
|
19
22
|
brief: null,
|
|
20
23
|
comment_created_at: null,
|
|
24
|
+
status: "unbound",
|
|
21
25
|
});
|
|
22
26
|
});
|
|
23
27
|
|
|
24
|
-
it("
|
|
25
|
-
// The task
|
|
26
|
-
//
|
|
28
|
+
it("classifies a task with NO comment as no_comment (work_item_id SET)", () => {
|
|
29
|
+
// The task resolved but no spawn comment landed. work_item_id is now SET
|
|
30
|
+
// (the task IS bound) — distinct from unbound — and status:"no_comment"
|
|
31
|
+
// is the broken state a fleet lane halts on rather than reading as success.
|
|
27
32
|
expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
|
|
28
|
-
work_item_id:
|
|
33
|
+
work_item_id: "task-1",
|
|
34
|
+
comment_id: null,
|
|
35
|
+
brief: null,
|
|
36
|
+
comment_created_at: null,
|
|
37
|
+
status: "no_comment",
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("classifies a task with a BLANK comment as empty_brief (the unreadable case)", () => {
|
|
42
|
+
// The comment exists but its content is empty/whitespace — "loads it
|
|
43
|
+
// empty". A fleet lane whose spawn minted a task MUST have a non-empty
|
|
44
|
+
// brief, so a blank comment is a defect the hook halts on, not a success.
|
|
45
|
+
expect(shapeBriefResponse({ id: "task-2" }, { id: "c-2", content: " ", created_at: new Date() })).toEqual({
|
|
46
|
+
work_item_id: "task-2",
|
|
47
|
+
comment_id: "c-2",
|
|
29
48
|
brief: null,
|
|
30
49
|
comment_created_at: null,
|
|
50
|
+
status: "empty_brief",
|
|
51
|
+
});
|
|
52
|
+
expect(shapeBriefResponse({ id: "task-2b" }, { id: "c-2b", content: "", created_at: new Date() })).toEqual({
|
|
53
|
+
work_item_id: "task-2b",
|
|
54
|
+
comment_id: "c-2b",
|
|
55
|
+
brief: null,
|
|
56
|
+
comment_created_at: null,
|
|
57
|
+
status: "empty_brief",
|
|
31
58
|
});
|
|
32
59
|
});
|
|
33
60
|
|
|
34
|
-
it("returns the comment content + ISO created_at for a resolved task+comment", () => {
|
|
61
|
+
it("returns the comment content + ISO created_at + status ready for a resolved task+comment", () => {
|
|
35
62
|
const ts = new Date("2026-08-01T03:24:00.000Z");
|
|
36
|
-
expect(shapeBriefResponse({ id: "task-7" }, { content: "Do the thing", created_at: ts })).toEqual({
|
|
63
|
+
expect(shapeBriefResponse({ id: "task-7" }, { id: "c-7", content: "Do the thing", created_at: ts })).toEqual({
|
|
37
64
|
work_item_id: "task-7",
|
|
65
|
+
comment_id: "c-7",
|
|
38
66
|
brief: "Do the thing",
|
|
39
67
|
comment_created_at: "2026-08-01T03:24:00.000Z",
|
|
68
|
+
status: "ready",
|
|
40
69
|
});
|
|
41
70
|
});
|
|
42
71
|
|
|
43
72
|
it("serializes the comment created_at deterministically (Date → ISO)", () => {
|
|
44
73
|
const ts = new Date(0);
|
|
45
|
-
const out = shapeBriefResponse({ id: "t" }, { content: "c", created_at: ts });
|
|
74
|
+
const out = shapeBriefResponse({ id: "t" }, { id: "c", content: "c", created_at: ts });
|
|
46
75
|
expect(out.comment_created_at).toBe("1970-01-01T00:00:00.000Z");
|
|
47
76
|
});
|
|
48
77
|
});
|
|
49
78
|
|
|
50
|
-
describe("readAssignedBrief reads the
|
|
79
|
+
describe("readAssignedBrief reads the FIRST launch comment (source contract)", () => {
|
|
51
80
|
const briefSrc = Bun.file(new URL("./task-sync-brief.ts", import.meta.url).pathname);
|
|
52
81
|
|
|
53
|
-
it("queries comments
|
|
54
|
-
// The comment
|
|
55
|
-
// LATEST comment (this run's) the answer, never a stale prior-run comment.
|
|
82
|
+
it("queries comments oldest-first with an id tie-breaker", async () => {
|
|
83
|
+
// The first comment is immutable launch identity; later comments are dialogue.
|
|
56
84
|
const text = await briefSrc.text();
|
|
57
|
-
expect(text).toContain('orderBy: { created_at: "
|
|
85
|
+
expect(text).toContain('orderBy: [{ created_at: "asc" }, { id: "asc" }]');
|
|
58
86
|
expect(text).toContain("work_item_id: workItemId");
|
|
59
87
|
});
|
|
60
88
|
|
|
@@ -64,14 +92,29 @@ describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
|
|
|
64
92
|
const text = await briefSrc.text();
|
|
65
93
|
expect(text).toContain("resolveLaneTask(prisma, session)");
|
|
66
94
|
});
|
|
95
|
+
|
|
96
|
+
it("resolves by the authoritative work_item_id BEFORE the session (deletes the bind race)", async () => {
|
|
97
|
+
// The spawn's known task id (SCALA_LANE_WORK_ITEM) cannot race — the row
|
|
98
|
+
// exists because the spawn created it — so resolving by it first makes an
|
|
99
|
+
// empty brief for a fleet lane unambiguously broken rather than a
|
|
100
|
+
// transient "retry" the lane can never exit (absent-input-reads-as-success).
|
|
101
|
+
const text = await briefSrc.text();
|
|
102
|
+
expect(text).toContain("workItemId");
|
|
103
|
+
expect(text).toContain("resolveTaskById(prisma, workItemId)");
|
|
104
|
+
// The session bind is no longer load-bearing for brief delivery: it stays
|
|
105
|
+
// only as the fallback for callers without a work_item_id.
|
|
106
|
+
const idIdx = text.indexOf("resolveTaskById(prisma, workItemId)");
|
|
107
|
+
const sessionIdx = text.indexOf("resolveLaneTask(prisma, session)");
|
|
108
|
+
expect(idIdx).toBeGreaterThan(-1);
|
|
109
|
+
expect(sessionIdx).toBeGreaterThan(idIdx);
|
|
110
|
+
});
|
|
67
111
|
});
|
|
68
112
|
|
|
69
|
-
describe("
|
|
113
|
+
describe("readFirstTaskComment is a single deterministic launch-comment read", () => {
|
|
70
114
|
it("returns null when the prisma client has no comments model (feature-detect)", async () => {
|
|
71
|
-
// `comments` is a universal shared model
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
const out = await readLatestTaskComment({} as never, "task-1");
|
|
115
|
+
// `comments` is a universal shared model; a client without it degrades to
|
|
116
|
+
// "no brief" rather than throwing.
|
|
117
|
+
const out = await readFirstTaskComment({} as never, "task-1");
|
|
75
118
|
expect(out).toBe(null);
|
|
76
119
|
});
|
|
77
120
|
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Lane BRIEF resolution for task-sync: a spawned lane PULLS its own brief. The
|
|
7
7
|
* lane's CLI extension reads its own session id on load, resolves the task
|
|
8
|
-
* assigned to that session, and reads the brief from that task's
|
|
8
|
+
* assigned to that session, and reads the brief from that task's FIRST comment.
|
|
9
9
|
* Nothing is pasted, typed, passed as an argument or sent over a channel
|
|
10
10
|
* (`scala-docs/reference/action-emission-architecture.md` "The lane PULLS its
|
|
11
11
|
* brief"). This file owns the readAssignedBrief ORPC handler + its pure helper;
|
|
@@ -25,38 +25,77 @@
|
|
|
25
25
|
import type { Prisma, CommentRow } from "./task-sync-router.ts";
|
|
26
26
|
import type { WorkItem } from "./task-sync-router.ts";
|
|
27
27
|
import { authed, type Context } from "./auth-router.ts";
|
|
28
|
-
import { resolveLaneTask } from "./task-sync-lane.ts";
|
|
28
|
+
import { resolveLaneTask, resolveTaskById } from "./task-sync-lane.ts";
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
31
|
+
* The outcome of a brief read, single-sourced here so every CLI's self-brief
|
|
32
|
+
* hook (zerostack/codex shell, opencode/pi TS) branches on ONE field rather
|
|
33
|
+
* than each re-deriving "is the brief missing" from null/empty probes — the
|
|
34
|
+
* seam at which absent-input-reads-as-success hid (`the-severe-bugs-live-at-seams`).
|
|
35
|
+
*
|
|
36
|
+
* The hook decides ACTION from this; the classification lives here. A fleet
|
|
37
|
+
* lane (SCALA_LANE_WORK_ITEM set) treats anything but "ready" as a hard halt.
|
|
38
|
+
*/
|
|
39
|
+
export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Shape the readAssignedBrief response from a resolved task + its first
|
|
32
43
|
* comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
|
|
33
|
-
* verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
44
|
+
* verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
|
|
45
|
+
*
|
|
46
|
+
* `work_item_id` is set iff the TASK resolved (independent of whether a comment
|
|
47
|
+
* exists), and `status` distinguishes the four states a hook must act on:
|
|
48
|
+
* - "ready" — task + a non-empty comment: inject the brief.
|
|
49
|
+
* - "empty_brief" — task resolved, comment exists but content is blank: the
|
|
50
|
+
* spawn minted a task with no usable brief — broken.
|
|
51
|
+
* - "no_comment" — task resolved, no comment at all: same broken class.
|
|
52
|
+
* - "unbound" — no task resolved: the lane has no brief task (a non-fleet
|
|
53
|
+
* operator tab, or — for the session-only path — a bind
|
|
54
|
+
* that has not yet commit; the work_item_id path cannot
|
|
55
|
+
* reach this for a fleet lane).
|
|
56
|
+
*
|
|
57
|
+
* "empty_brief" / "no_comment" with the task resolved is the state the
|
|
58
|
+
* absent-input fix exists to catch: a fleet lane whose spawn succeeded MUST
|
|
59
|
+
* have a non-empty brief comment, so its absence is a defect the hook halts on
|
|
60
|
+
* rather than reading as success.
|
|
36
61
|
*/
|
|
37
62
|
export function shapeBriefResponse(
|
|
38
63
|
task: { id: string } | null,
|
|
39
|
-
comment: Pick<CommentRow, "content" | "created_at"> | null,
|
|
40
|
-
): {
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
comment: Pick<CommentRow, "id" | "content" | "created_at"> | null,
|
|
65
|
+
): {
|
|
66
|
+
work_item_id: string | null;
|
|
67
|
+
comment_id: string | null;
|
|
68
|
+
brief: string | null;
|
|
69
|
+
comment_created_at: string | null;
|
|
70
|
+
status: BriefReadStatus;
|
|
71
|
+
} {
|
|
72
|
+
if (!task) {
|
|
73
|
+
return { work_item_id: null, comment_id: null, brief: null, comment_created_at: null, status: "unbound" };
|
|
74
|
+
}
|
|
75
|
+
if (!comment) {
|
|
76
|
+
return { work_item_id: task.id, comment_id: null, brief: null, comment_created_at: null, status: "no_comment" };
|
|
77
|
+
}
|
|
78
|
+
const text = typeof comment.content === "string" ? comment.content : String(comment.content);
|
|
79
|
+
if (text.trim() === "") {
|
|
80
|
+
return { work_item_id: task.id, comment_id: comment.id, brief: null, comment_created_at: null, status: "empty_brief" };
|
|
43
81
|
}
|
|
44
82
|
return {
|
|
45
83
|
work_item_id: task.id,
|
|
46
|
-
|
|
84
|
+
comment_id: comment.id,
|
|
85
|
+
brief: text,
|
|
47
86
|
comment_created_at:
|
|
48
87
|
comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
|
|
88
|
+
status: "ready",
|
|
49
89
|
};
|
|
50
90
|
}
|
|
51
91
|
|
|
52
92
|
/**
|
|
53
|
-
* Read the
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* task has no comment yet — the extension treats that as "no brief to inject".
|
|
93
|
+
* Read the immutable FIRST comment on a work_items task. It is the launch brief;
|
|
94
|
+
* later comments are conversation and must never change the launch identity.
|
|
95
|
+
* Oldest-first by created_at with id as a deterministic tie-breaker. Returns null
|
|
96
|
+
* when the task has no comment yet.
|
|
58
97
|
*/
|
|
59
|
-
export async function
|
|
98
|
+
export async function readFirstTaskComment(
|
|
60
99
|
prisma: Prisma,
|
|
61
100
|
workItemId: string,
|
|
62
101
|
): Promise<CommentRow | null> {
|
|
@@ -70,7 +109,7 @@ export async function readLatestTaskComment(
|
|
|
70
109
|
if (!comments?.findFirst) return null;
|
|
71
110
|
return comments.findFirst({
|
|
72
111
|
where: { work_item_id: workItemId },
|
|
73
|
-
orderBy: { created_at: "
|
|
112
|
+
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
|
74
113
|
});
|
|
75
114
|
}
|
|
76
115
|
|
|
@@ -84,14 +123,29 @@ export async function readLatestTaskComment(
|
|
|
84
123
|
export const readAssignedBriefHandler = authed.handler(async ({ input, context }) => {
|
|
85
124
|
const ctx = context as Context;
|
|
86
125
|
const prisma = ctx.prisma as Prisma;
|
|
87
|
-
const body = (input ?? {}) as { lane_session?: string };
|
|
126
|
+
const body = (input ?? {}) as { lane_session?: string; work_item_id?: string };
|
|
88
127
|
const session = body.lane_session ?? ctx.headers?.get("x-lane-session") ?? null;
|
|
89
|
-
|
|
128
|
+
// The spawn's known task id (SCALA_LANE_WORK_ITEM) — AUTHORITATIVE for brief
|
|
129
|
+
// resolution. Resolving by it first deletes the bindLane race that made an
|
|
130
|
+
// empty brief read as a transient "retry" a fleet lane could never exit
|
|
131
|
+
// (absent-input-reads-as-success). Falls back to session for callers without
|
|
132
|
+
// it (operator tabs / pre-id lanes).
|
|
133
|
+
const workItemId =
|
|
134
|
+
body.work_item_id?.trim() || ctx.headers?.get("x-lane-work-item")?.trim() || null;
|
|
135
|
+
if (!session && !workItemId) {
|
|
90
136
|
const { ORPCError } = await import("@orpc/server");
|
|
91
|
-
throw new ORPCError("BAD_REQUEST", {
|
|
137
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
138
|
+
message: "readAssignedBrief requires lane_session or work_item_id",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
let task: (WorkItem & { id: string }) | null = null;
|
|
142
|
+
if (workItemId) {
|
|
143
|
+
task = (await resolveTaskById(prisma, workItemId)) as (WorkItem & { id: string }) | null;
|
|
144
|
+
}
|
|
145
|
+
if (!task && session) {
|
|
146
|
+
task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
|
|
92
147
|
}
|
|
93
|
-
const task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
|
|
94
148
|
if (!task) return shapeBriefResponse(null, null);
|
|
95
|
-
const comment = await
|
|
149
|
+
const comment = await readFirstTaskComment(prisma, task.id);
|
|
96
150
|
return shapeBriefResponse(task, comment);
|
|
97
151
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import { closedTaskState } from "./task-sync-close.ts";
|
|
9
|
+
|
|
10
|
+
const task = {
|
|
11
|
+
id: "task-1",
|
|
12
|
+
kind_data: {
|
|
13
|
+
lane: { session: "acp-session", cli_kind: "zerostack", live: true },
|
|
14
|
+
assignee_type: "agent",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("closedTaskState", () => {
|
|
19
|
+
it("marks completed work done and the lane non-live", () => {
|
|
20
|
+
expect(closedTaskState(task, "completed")).toEqual({
|
|
21
|
+
task_id: "task-1",
|
|
22
|
+
task_status: "done",
|
|
23
|
+
completed: true,
|
|
24
|
+
kind_data: {
|
|
25
|
+
lane: { session: "acp-session", cli_kind: "zerostack", live: false },
|
|
26
|
+
assignee_type: "agent",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("archives every disposition that does not prove completion", () => {
|
|
32
|
+
for (const disposition of [
|
|
33
|
+
"needs_retry",
|
|
34
|
+
"failed",
|
|
35
|
+
"orchestrator_closed",
|
|
36
|
+
"orphaned",
|
|
37
|
+
] as const) {
|
|
38
|
+
const state = closedTaskState(task, disposition);
|
|
39
|
+
expect(state.task_status).toBe("archived");
|
|
40
|
+
expect(state.completed).toBe(false);
|
|
41
|
+
expect((state.kind_data.lane as { live: boolean }).live).toBe(false);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Lane-close write-through for task-sync. The session-owning fleet runtime
|
|
7
|
+
* calls this ORPC procedure before releasing an ACP lane, so work_items remains
|
|
8
|
+
* the durable lifecycle source without a second raw-SQL writer in the host.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ORPCError } from "@orpc/server";
|
|
12
|
+
import * as v from "valibot";
|
|
13
|
+
import { authed, type Context } from "./auth-router.ts";
|
|
14
|
+
import type { Prisma, WorkItem } from "./task-sync-router.ts";
|
|
15
|
+
|
|
16
|
+
export const closeLaneInput = v.object({
|
|
17
|
+
lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
|
|
18
|
+
disposition: v.picklist([
|
|
19
|
+
"completed",
|
|
20
|
+
"needs_retry",
|
|
21
|
+
"failed",
|
|
22
|
+
"orchestrator_closed",
|
|
23
|
+
"orphaned",
|
|
24
|
+
]),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface ClosedTaskState {
|
|
28
|
+
task_id: string;
|
|
29
|
+
task_status: "done" | "archived";
|
|
30
|
+
completed: boolean;
|
|
31
|
+
kind_data: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function closedTaskState(
|
|
35
|
+
task: Pick<WorkItem, "id" | "kind_data">,
|
|
36
|
+
disposition: v.InferOutput<typeof closeLaneInput>["disposition"],
|
|
37
|
+
): ClosedTaskState {
|
|
38
|
+
const completed = disposition === "completed";
|
|
39
|
+
const lane =
|
|
40
|
+
task.kind_data.lane && typeof task.kind_data.lane === "object"
|
|
41
|
+
? (task.kind_data.lane as Record<string, unknown>)
|
|
42
|
+
: {};
|
|
43
|
+
return {
|
|
44
|
+
task_id: task.id,
|
|
45
|
+
task_status: completed ? "done" : "archived",
|
|
46
|
+
completed,
|
|
47
|
+
kind_data: { ...task.kind_data, lane: { ...lane, live: false } },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const closeLaneHandler = authed
|
|
52
|
+
.input(closeLaneInput)
|
|
53
|
+
.handler(async ({ input, context }) => {
|
|
54
|
+
const prisma = (context as Context).prisma as Prisma;
|
|
55
|
+
const task = await prisma.work_items.findFirst({
|
|
56
|
+
where: {
|
|
57
|
+
kind: "task",
|
|
58
|
+
kind_data: { path: ["lane", "session"], equals: input.lane_session },
|
|
59
|
+
},
|
|
60
|
+
orderBy: { created_at: "desc" },
|
|
61
|
+
});
|
|
62
|
+
if (!task) {
|
|
63
|
+
throw new ORPCError("NOT_FOUND", {
|
|
64
|
+
message: `No task is bound to ACP lane ${input.lane_session}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const state = closedTaskState(task, input.disposition);
|
|
68
|
+
await prisma.work_items.update({
|
|
69
|
+
where: { id: task.id },
|
|
70
|
+
data: {
|
|
71
|
+
kind_data: state.kind_data,
|
|
72
|
+
status: state.task_status,
|
|
73
|
+
completed: state.completed,
|
|
74
|
+
},
|
|
75
|
+
select: { id: true },
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
task_id: state.task_id,
|
|
79
|
+
task_status: state.task_status,
|
|
80
|
+
reconciled: true,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
@@ -3,42 +3,60 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
* Unit tests for the pure
|
|
7
|
-
*
|
|
8
|
-
* DB->AGENT direction demo — a published orchestrator.message with slotName
|
|
9
|
-
* cross-pane delivers to the target pane's <channel> tag); buildTaskChangedEvent
|
|
10
|
-
* is pure and tested here. Satisfies what-must-have-a-test / primitive-api-has-test.
|
|
6
|
+
* Unit tests for the pure durable comment-payload builder in task-sync-dispatch.
|
|
7
|
+
* The runtime path writes this payload through the canonical comments model.
|
|
11
8
|
*/
|
|
12
9
|
import { describe, it, expect } from "bun:test";
|
|
13
|
-
import {
|
|
10
|
+
import { buildTaskChangeComment, notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
|
|
14
11
|
|
|
15
|
-
describe("task-sync
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
12
|
+
describe("task-sync buildTaskChangeComment", () => {
|
|
13
|
+
it("addresses the canonical task and runtime with no pane transport", () => {
|
|
14
|
+
const comment = buildTaskChangeComment("task-7", "session-5", "subtask-1:completed");
|
|
15
|
+
expect(comment).toMatchObject({
|
|
16
|
+
work_item_id: "task-7",
|
|
17
|
+
source_cli_session_id: "session-5",
|
|
18
|
+
sender_role: "system",
|
|
19
|
+
sender_name: "TaskSync",
|
|
20
|
+
});
|
|
21
|
+
expect(comment.system_key).toBe("task-sync:task-7:session-5:subtask-1:completed");
|
|
22
|
+
expect(comment.content).toContain("readTaskList");
|
|
23
|
+
expect(comment).not.toHaveProperty("pane");
|
|
24
|
+
});
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
it("writes one durable comment addressed by the active cli_session", async () => {
|
|
27
|
+
const calls: unknown[] = [];
|
|
28
|
+
const result = await notifyLaneOfTaskChange(
|
|
29
|
+
{
|
|
30
|
+
cli_sessions: { findFirst: async () => ({ id: "session-5" }) },
|
|
31
|
+
comments: {
|
|
32
|
+
upsert: async (args) => {
|
|
33
|
+
calls.push(args);
|
|
34
|
+
return { id: "comment-1" };
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
"task-7",
|
|
39
|
+
"subtask-1:completed",
|
|
40
|
+
);
|
|
41
|
+
expect(result).toMatchObject({ delivered: true, sessionId: "session-5", commentId: "comment-1" });
|
|
42
|
+
expect(calls[0]).toMatchObject({ create: { work_item_id: "task-7", source_cli_session_id: "session-5" } });
|
|
43
|
+
});
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
it("does not fabricate a pre-launch session or comment", async () => {
|
|
46
|
+
let writes = 0;
|
|
47
|
+
const result = await notifyLaneOfTaskChange(
|
|
48
|
+
{
|
|
49
|
+
cli_sessions: { findFirst: async () => null },
|
|
50
|
+
comments: {
|
|
51
|
+
upsert: async () => {
|
|
52
|
+
writes++;
|
|
53
|
+
return { id: "unexpected" };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"task-7",
|
|
58
|
+
);
|
|
59
|
+
expect(result).toEqual({ delivered: false, sessionId: null, commentId: null });
|
|
60
|
+
expect(writes).toBe(0);
|
|
61
|
+
});
|
|
43
62
|
});
|
|
44
|
-
});
|