@teamscala/service-boot 0.23.329 → 0.23.331
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 +538 -522
- package/src/agreement-gate.d.ts +19 -0
- package/src/asset-cache-key.d.ts +11 -0
- package/src/asset-optimize.d.ts +17 -0
- package/src/asset-proxy.d.ts +11 -0
- package/src/asset-social-card.d.ts +17 -0
- package/src/attach-agreement-magic.d.ts +20 -0
- package/src/attach-portal-members.d.ts +23 -0
- package/src/attach-proposal-accept.d.ts +26 -0
- package/src/attach-proposal-magic.d.ts +18 -0
- package/src/attach-transfers-upload.d.ts +27 -0
- package/src/auth-gate.d.ts +28 -0
- package/src/auth-gate.ts +6 -68
- package/src/bind-page-handler.d.ts +18 -0
- package/src/build-client-assets/input-hashing.d.ts +25 -0
- package/src/build-client-assets/source-candidates.d.ts +27 -0
- package/src/build-client-assets.d.ts +50 -0
- package/src/build-client-assets.test.ts +1 -1
- package/src/build-client-assets.ts +1 -1
- package/src/compose-page-title.d.ts +41 -0
- package/src/content-gate/access.d.ts +15 -0
- package/src/content-gate/config-types.d.ts +99 -0
- package/src/content-gate/gate.d.ts +13 -0
- package/src/content-gate/parse-cookie.d.ts +9 -0
- package/src/content-gate/token.d.ts +48 -0
- package/src/content-page-floor.d.ts +39 -0
- package/src/discover-blocks.d.ts +28 -0
- package/src/discover-blocks.ts +1 -1
- package/src/expand-roster.d.ts +34 -0
- package/src/fn-routers/agreement-completion-email.d.ts +14 -0
- package/src/fn-routers/agreement-send-router.d.ts +82 -0
- package/src/fn-routers/ai-health-router.d.ts +35 -0
- package/src/fn-routers/auth-router.d.ts +47 -0
- package/src/fn-routers/cache-admin-router.d.ts +57 -0
- package/src/fn-routers/chat-session-router.d.ts +219 -0
- package/src/fn-routers/email-defaults.d.ts +1 -0
- package/src/fn-routers/email-link-ttl.d.ts +1 -0
- package/src/fn-routers/index.d.ts +18 -0
- package/src/fn-routers/index.ts +0 -2
- package/src/fn-routers/panel-data-router.d.ts +19 -0
- package/src/fn-routers/persist-chat-messages.d.ts +36 -0
- package/src/fn-routers/plans-router.d.ts +25 -0
- package/src/fn-routers/proposal-send-router.d.ts +58 -0
- package/src/fn-routers/public-agreement-router.d.ts +289 -0
- package/src/fn-routers/public-assessment-router.d.ts +68 -0
- package/src/fn-routers/public-gpt-session-router.d.ts +143 -0
- package/src/fn-routers/sign-in-flow-router.d.ts +107 -0
- package/src/fn-routers/task-sync-assign.d.ts +305 -0
- package/src/fn-routers/task-sync-assign.test.ts +1 -2
- package/src/fn-routers/task-sync-assign.ts +4 -4
- package/src/fn-routers/task-sync-bind.test.ts +18 -18
- package/src/fn-routers/task-sync-brief.d.ts +96 -0
- package/src/fn-routers/task-sync-brief.test.ts +17 -20
- package/src/fn-routers/task-sync-brief.ts +14 -15
- package/src/fn-routers/task-sync-close.d.ts +41 -0
- package/src/fn-routers/task-sync-dispatch.d.ts +61 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +33 -51
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-lane.d.ts +49 -0
- package/src/fn-routers/task-sync-lane.ts +125 -0
- package/src/fn-routers/task-sync-projection.d.ts +121 -0
- package/src/fn-routers/task-sync-projection.test.ts +0 -26
- package/src/fn-routers/task-sync-projection.ts +0 -51
- package/src/fn-routers/task-sync-rename.d.ts +91 -0
- package/src/fn-routers/task-sync-router.d.ts +444 -0
- package/src/fn-routers/task-sync-router.test.ts +2 -14
- package/src/fn-routers/task-sync-router.ts +1111 -0
- package/src/fn-routers/tasks-router.d.ts +25 -0
- package/src/fn-routers/user-preferences-router.d.ts +77 -0
- package/src/fn-routers/work-items-router.d.ts +102 -0
- package/src/gate-ttls.d.ts +6 -0
- package/src/gpt-gate.d.ts +25 -0
- package/src/handlers/bun-stable-watch.d.ts +1 -0
- package/src/handlers/pane-outbox-drain.d.ts +1 -0
- package/src/handlers/projects-digest.d.ts +26 -0
- package/src/handlers/prune-stale-data.d.ts +30 -0
- package/src/handlers/registry-stale-inactive-sweep.d.ts +1 -0
- package/src/handlers/scala-tools-currency-watch.d.ts +1 -0
- package/src/handlers/singleton-tools-currency-watch.d.ts +1 -0
- package/src/handlers/sweep-event-log.d.ts +1 -0
- package/src/handlers/sweep-host-cache.d.ts +1 -0
- package/src/handlers/sweep-host-cache.ts +26 -143
- package/src/handlers/upstream-issue-sync.d.ts +1 -0
- package/src/handlers/whatsapp-liveness-logic.d.ts +34 -0
- package/src/handlers/whatsapp-liveness.d.ts +4 -0
- package/src/hono-error-handler.d.ts +25 -0
- package/src/install-harness.d.ts +45 -0
- package/src/install-logging.d.ts +34 -0
- package/src/load-service-install-row.d.ts +24 -0
- package/src/load-service-prisma-client.d.ts +18 -0
- package/src/mount-orpc-router.d.ts +15 -0
- package/src/mount-orpc-router.ts +1 -1
- package/src/mount-server-packages.d.ts +29 -0
- package/src/mount.d.ts +9 -0
- package/src/mutable-app-ref.d.ts +44 -0
- package/src/page-route-shell-types.d.ts +12 -0
- package/src/page-server-orpc/audit-revert-router.d.ts +37 -0
- package/src/page-server-orpc/hub-mcp-client.d.ts +12 -0
- package/src/page-server-orpc/portal-diagnostics-router.d.ts +49 -0
- package/src/page-server-orpc/puck-pages-router.d.ts +58 -0
- package/src/page-server-orpc/server-client.d.ts +16 -0
- package/src/page-server-orpc/templates-router.d.ts +34 -0
- package/src/page-server-orpc/user-preferences-router.d.ts +79 -0
- package/src/pg-boss-handler-registry.d.ts +27 -0
- package/src/pg-boss-mount.d.ts +42 -0
- package/src/proposal-authorized-emails.d.ts +28 -0
- package/src/proposal-chat-inject.d.ts +17 -0
- package/src/proposal-gate.d.ts +18 -0
- package/src/read-html-template.d.ts +38 -0
- package/src/read-viewport-from-request.d.ts +18 -0
- package/src/readiness-gate.d.ts +16 -0
- package/src/register-api-routes.d.ts +18 -0
- package/src/register-crash-handlers.d.ts +15 -0
- package/src/register-page-routes.d.ts +27 -0
- package/src/register-page-routes.ts +3 -0
- package/src/register-page-server-client.d.ts +11 -0
- package/src/register-registry-page-route/ai-chat-prefetch.d.ts +13 -0
- package/src/register-registry-page-route/graph-cache.d.ts +30 -0
- package/src/register-registry-page-route/meta-title.d.ts +2 -0
- package/src/register-registry-page-route/proposal-approval-email-token.d.ts +14 -0
- package/src/register-registry-page-route/proposal-brand-stamping.d.ts +28 -0
- package/src/register-registry-page-route/resolve-page-data.d.ts +1 -0
- package/src/register-registry-page-route.d.ts +27 -0
- package/src/register-static-assets.d.ts +13 -0
- package/src/register-ws-routes.d.ts +28 -0
- package/src/resolve-app-base-url.d.ts +13 -0
- package/src/resolve-brand-favicon.d.ts +28 -0
- package/src/resolve-brand-favicon.ts +1 -1
- package/src/resolve-from-loader-config.d.ts +32 -0
- package/src/resolve-service-context.d.ts +17 -0
- package/src/resolve-service-port.d.ts +75 -0
- package/src/resolve-theme-mode.d.ts +42 -0
- package/src/resolvers/fetch-org-brand.d.ts +2 -0
- package/src/resolvers/portal-route-paths.d.ts +39 -0
- package/src/resolvers/website-by-host-map.d.ts +12 -0
- package/src/resource-chat-inject.d.ts +46 -0
- package/src/resource-not-found.d.ts +60 -0
- package/src/run-boot-hook-list.d.ts +19 -0
- package/src/run-boot-hooks.d.ts +31 -0
- package/src/scala-tools-currency-watchdog.d.ts +10 -0
- package/src/security-headers.d.ts +12 -0
- package/src/serve/serve.d.ts +46 -0
- package/src/serve/stamp-transport-identity.d.ts +32 -0
- package/src/serve/stamp-transport-identity.ts +2 -14
- package/src/server-error-page.d.ts +29 -0
- package/src/start-scheduled-jobs.d.ts +18 -0
- package/src/two-phase-boot.d.ts +136 -0
- package/src/two-phase-boot.ts +17 -85
- package/src/two-phase-health-gate.test.ts +2 -13
- package/src/types.d.ts +46 -0
- package/src/ui-service.d.ts +46 -0
- package/src/ws-boot-self-test.d.ts +19 -0
- package/src/auth-gate.test.ts +0 -70
- package/src/fn-routers/task-sync-lane.test.ts +0 -36
- package/src/handlers/sweep-host-cache.test.ts +0 -133
- package/src/serve/stamp-transport-identity.test.ts +0 -24
- package/src/two-phase-health-gate-client-only.test.ts +0 -77
|
@@ -5,20 +5,19 @@
|
|
|
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
|
+
* readLatestTaskComment reads the LATEST 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, readLatestTaskComment } from "./task-sync-brief.ts";
|
|
13
13
|
|
|
14
14
|
describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
15
15
|
it("classifies a missing task as unbound (work_item_id null)", () => {
|
|
16
16
|
// A lane with no resolvable task has no brief — null, never an empty fake.
|
|
17
17
|
// status:"unbound" is the signal a non-fleet lane no-ops on and a fleet
|
|
18
18
|
// lane (which resolves by work_item_id, so cannot reach this) never sees.
|
|
19
|
-
expect(shapeBriefResponse(null, {
|
|
19
|
+
expect(shapeBriefResponse(null, { content: "x", created_at: new Date() })).toEqual({
|
|
20
20
|
work_item_id: null,
|
|
21
|
-
comment_id: null,
|
|
22
21
|
brief: null,
|
|
23
22
|
comment_created_at: null,
|
|
24
23
|
status: "unbound",
|
|
@@ -31,7 +30,6 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
31
30
|
// is the broken state a fleet lane halts on rather than reading as success.
|
|
32
31
|
expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
|
|
33
32
|
work_item_id: "task-1",
|
|
34
|
-
comment_id: null,
|
|
35
33
|
brief: null,
|
|
36
34
|
comment_created_at: null,
|
|
37
35
|
status: "no_comment",
|
|
@@ -42,16 +40,14 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
42
40
|
// The comment exists but its content is empty/whitespace — "loads it
|
|
43
41
|
// empty". A fleet lane whose spawn minted a task MUST have a non-empty
|
|
44
42
|
// brief, so a blank comment is a defect the hook halts on, not a success.
|
|
45
|
-
expect(shapeBriefResponse({ id: "task-2" }, {
|
|
43
|
+
expect(shapeBriefResponse({ id: "task-2" }, { content: " ", created_at: new Date() })).toEqual({
|
|
46
44
|
work_item_id: "task-2",
|
|
47
|
-
comment_id: "c-2",
|
|
48
45
|
brief: null,
|
|
49
46
|
comment_created_at: null,
|
|
50
47
|
status: "empty_brief",
|
|
51
48
|
});
|
|
52
|
-
expect(shapeBriefResponse({ id: "task-2b" }, {
|
|
49
|
+
expect(shapeBriefResponse({ id: "task-2b" }, { content: "", created_at: new Date() })).toEqual({
|
|
53
50
|
work_item_id: "task-2b",
|
|
54
|
-
comment_id: "c-2b",
|
|
55
51
|
brief: null,
|
|
56
52
|
comment_created_at: null,
|
|
57
53
|
status: "empty_brief",
|
|
@@ -60,9 +56,8 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
60
56
|
|
|
61
57
|
it("returns the comment content + ISO created_at + status ready for a resolved task+comment", () => {
|
|
62
58
|
const ts = new Date("2026-08-01T03:24:00.000Z");
|
|
63
|
-
expect(shapeBriefResponse({ id: "task-7" }, {
|
|
59
|
+
expect(shapeBriefResponse({ id: "task-7" }, { content: "Do the thing", created_at: ts })).toEqual({
|
|
64
60
|
work_item_id: "task-7",
|
|
65
|
-
comment_id: "c-7",
|
|
66
61
|
brief: "Do the thing",
|
|
67
62
|
comment_created_at: "2026-08-01T03:24:00.000Z",
|
|
68
63
|
status: "ready",
|
|
@@ -71,18 +66,19 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
71
66
|
|
|
72
67
|
it("serializes the comment created_at deterministically (Date → ISO)", () => {
|
|
73
68
|
const ts = new Date(0);
|
|
74
|
-
const out = shapeBriefResponse({ id: "t" }, {
|
|
69
|
+
const out = shapeBriefResponse({ id: "t" }, { content: "c", created_at: ts });
|
|
75
70
|
expect(out.comment_created_at).toBe("1970-01-01T00:00:00.000Z");
|
|
76
71
|
});
|
|
77
72
|
});
|
|
78
73
|
|
|
79
|
-
describe("readAssignedBrief reads the
|
|
74
|
+
describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
|
|
80
75
|
const briefSrc = Bun.file(new URL("./task-sync-brief.ts", import.meta.url).pathname);
|
|
81
76
|
|
|
82
|
-
it("queries comments
|
|
83
|
-
// The
|
|
77
|
+
it("queries comments newest-first by created_at on work_item_id", async () => {
|
|
78
|
+
// The comment IS the brief for a run; ordering by created_at desc makes the
|
|
79
|
+
// LATEST comment (this run's) the answer, never a stale prior-run comment.
|
|
84
80
|
const text = await briefSrc.text();
|
|
85
|
-
expect(text).toContain('orderBy:
|
|
81
|
+
expect(text).toContain('orderBy: { created_at: "desc" }');
|
|
86
82
|
expect(text).toContain("work_item_id: workItemId");
|
|
87
83
|
});
|
|
88
84
|
|
|
@@ -110,11 +106,12 @@ describe("readAssignedBrief reads the FIRST launch comment (source contract)", (
|
|
|
110
106
|
});
|
|
111
107
|
});
|
|
112
108
|
|
|
113
|
-
describe("
|
|
109
|
+
describe("readLatestTaskComment is a single latest-comment read", () => {
|
|
114
110
|
it("returns null when the prisma client has no comments model (feature-detect)", async () => {
|
|
115
|
-
// `comments` is a universal shared model
|
|
116
|
-
//
|
|
117
|
-
|
|
111
|
+
// `comments` is a universal shared model, but the access is feature-detected
|
|
112
|
+
// like the fleet_agent_runs reads so a client without it degrades to "no
|
|
113
|
+
// brief" rather than throwing.
|
|
114
|
+
const out = await readLatestTaskComment({} as never, "task-1");
|
|
118
115
|
expect(out).toBe(null);
|
|
119
116
|
});
|
|
120
117
|
});
|
|
@@ -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 LATEST 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;
|
|
@@ -39,7 +39,7 @@ import { resolveLaneTask, resolveTaskById } from "./task-sync-lane.ts";
|
|
|
39
39
|
export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Shape the readAssignedBrief response from a resolved task + its
|
|
42
|
+
* Shape the readAssignedBrief response from a resolved task + its latest
|
|
43
43
|
* comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
|
|
44
44
|
* verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
|
|
45
45
|
*
|
|
@@ -61,27 +61,25 @@ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound"
|
|
|
61
61
|
*/
|
|
62
62
|
export function shapeBriefResponse(
|
|
63
63
|
task: { id: string } | null,
|
|
64
|
-
comment: Pick<CommentRow, "
|
|
64
|
+
comment: Pick<CommentRow, "content" | "created_at"> | null,
|
|
65
65
|
): {
|
|
66
66
|
work_item_id: string | null;
|
|
67
|
-
comment_id: string | null;
|
|
68
67
|
brief: string | null;
|
|
69
68
|
comment_created_at: string | null;
|
|
70
69
|
status: BriefReadStatus;
|
|
71
70
|
} {
|
|
72
71
|
if (!task) {
|
|
73
|
-
return { work_item_id: null,
|
|
72
|
+
return { work_item_id: null, brief: null, comment_created_at: null, status: "unbound" };
|
|
74
73
|
}
|
|
75
74
|
if (!comment) {
|
|
76
|
-
return { work_item_id: task.id,
|
|
75
|
+
return { work_item_id: task.id, brief: null, comment_created_at: null, status: "no_comment" };
|
|
77
76
|
}
|
|
78
77
|
const text = typeof comment.content === "string" ? comment.content : String(comment.content);
|
|
79
78
|
if (text.trim() === "") {
|
|
80
|
-
return { work_item_id: task.id,
|
|
79
|
+
return { work_item_id: task.id, brief: null, comment_created_at: null, status: "empty_brief" };
|
|
81
80
|
}
|
|
82
81
|
return {
|
|
83
82
|
work_item_id: task.id,
|
|
84
|
-
comment_id: comment.id,
|
|
85
83
|
brief: text,
|
|
86
84
|
comment_created_at:
|
|
87
85
|
comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
|
|
@@ -90,12 +88,13 @@ export function shapeBriefResponse(
|
|
|
90
88
|
}
|
|
91
89
|
|
|
92
90
|
/**
|
|
93
|
-
* Read the
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
91
|
+
* Read the LATEST comment on a work_items task. The comment IS the brief for a
|
|
92
|
+
* run: every spawn writes a NEW comment, and the description is never the brief,
|
|
93
|
+
* so "what was this lane told" always has exactly one answer
|
|
94
|
+
* (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
|
|
95
|
+
* task has no comment yet — the extension treats that as "no brief to inject".
|
|
97
96
|
*/
|
|
98
|
-
export async function
|
|
97
|
+
export async function readLatestTaskComment(
|
|
99
98
|
prisma: Prisma,
|
|
100
99
|
workItemId: string,
|
|
101
100
|
): Promise<CommentRow | null> {
|
|
@@ -109,7 +108,7 @@ export async function readFirstTaskComment(
|
|
|
109
108
|
if (!comments?.findFirst) return null;
|
|
110
109
|
return comments.findFirst({
|
|
111
110
|
where: { work_item_id: workItemId },
|
|
112
|
-
orderBy:
|
|
111
|
+
orderBy: { created_at: "desc" },
|
|
113
112
|
});
|
|
114
113
|
}
|
|
115
114
|
|
|
@@ -146,6 +145,6 @@ export const readAssignedBriefHandler = authed.handler(async ({ input, context }
|
|
|
146
145
|
task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
|
|
147
146
|
}
|
|
148
147
|
if (!task) return shapeBriefResponse(null, null);
|
|
149
|
-
const comment = await
|
|
148
|
+
const comment = await readLatestTaskComment(prisma, task.id);
|
|
150
149
|
return shapeBriefResponse(task, comment);
|
|
151
150
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import * as v from "valibot";
|
|
11
|
+
import { type Context } from "./auth-router.ts";
|
|
12
|
+
import type { WorkItem } from "./task-sync-router.ts";
|
|
13
|
+
export declare const closeLaneInput: v.ObjectSchema<{
|
|
14
|
+
readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
|
|
15
|
+
readonly disposition: v.PicklistSchema<["completed", "needs_retry", "failed", "orchestrator_closed", "orphaned"], undefined>;
|
|
16
|
+
}, undefined>;
|
|
17
|
+
export interface ClosedTaskState {
|
|
18
|
+
task_id: string;
|
|
19
|
+
task_status: "done" | "archived";
|
|
20
|
+
completed: boolean;
|
|
21
|
+
kind_data: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
export declare function closedTaskState(task: Pick<WorkItem, "id" | "kind_data">, disposition: v.InferOutput<typeof closeLaneInput>["disposition"]): ClosedTaskState;
|
|
24
|
+
export declare const closeLaneHandler: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
|
|
25
|
+
headers: Headers;
|
|
26
|
+
resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
|
|
27
|
+
prisma: any;
|
|
28
|
+
user: import("./auth-router.ts").User;
|
|
29
|
+
orgId: string | null | undefined;
|
|
30
|
+
}>, v.ObjectSchema<{
|
|
31
|
+
readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
|
|
32
|
+
readonly disposition: v.PicklistSchema<["completed", "needs_retry", "failed", "orchestrator_closed", "orphaned"], undefined>;
|
|
33
|
+
}, undefined>, import("@orpc/contract").Schema<{
|
|
34
|
+
task_id: string;
|
|
35
|
+
task_status: "archived" | "done";
|
|
36
|
+
reconciled: boolean;
|
|
37
|
+
}, {
|
|
38
|
+
task_id: string;
|
|
39
|
+
task_status: "archived" | "done";
|
|
40
|
+
reconciled: boolean;
|
|
41
|
+
}>, Record<never, never>, Record<never, never>>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* DB→AGENT dispatch for task-sync — notifies a lane's live pane that its
|
|
7
|
+
* work_items task list changed out-of-band (operator toggled a subtask in the
|
|
8
|
+
* projects UI, another lane edited a shared task). The pane's adapter then
|
|
9
|
+
* re-reads (fn.taskSync.readTaskList) and updates its native list.
|
|
10
|
+
*
|
|
11
|
+
* MECHANISM — reuses the existing CLI-agnostic channel delivery, no new
|
|
12
|
+
* transport: a POST to scala-channel's fleet-events publish entry
|
|
13
|
+
* (POST /api/fleet/events — @teamscala/channel/fleet-events-publish) carrying an
|
|
14
|
+
* `orchestrator.message` FleetEvent with slotName "cross-pane". That slot is
|
|
15
|
+
* the watchEvents bypass for events NOT tied to a fleet_agent_runs row
|
|
16
|
+
* (FleetEventOrchestratorMessageSchema comment) — exactly the system-originated
|
|
17
|
+
* case. scala-channel re-emits onto its eventBus and streamFleetEvents delivers
|
|
18
|
+
* to the subscriber filtered by orchestratorSessionId (the target lane pane).
|
|
19
|
+
* Both the Claude Code relay and the OpenCode scala-hooks plugin subscribe to
|
|
20
|
+
* that SSE stream for their own pane identity and inject each event natively —
|
|
21
|
+
* so ONE publish reaches every CLI that has a live projection, no per-CLI
|
|
22
|
+
* dispatch code here.
|
|
23
|
+
*
|
|
24
|
+
* NOT the dispatch entry (POST /api/channel/send): that path requires a real
|
|
25
|
+
* sender pane identity (channel-sender-identity-validated anti-spoof) and so
|
|
26
|
+
* cannot carry a system-originated notification. The publish path is
|
|
27
|
+
* internal-trusted (localhost super-admin) and accepts a synthetic
|
|
28
|
+
* fromOrchestratorId — the correct path for a system→pane message.
|
|
29
|
+
*
|
|
30
|
+
* scala-channel's address is resolved from its service_routing row (local_port)
|
|
31
|
+
* — never hardcoded (service-ports-from-registry). Fire-and-forget: a dispatch
|
|
32
|
+
* failure (scala-channel down, port unresolvable) is logged and swallowed; it
|
|
33
|
+
* must never block the work_items write that triggered it, and the lane's
|
|
34
|
+
* SessionStart/UserPromptSubmit backstop picks up the change on its next turn
|
|
35
|
+
* regardless.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The `orchestrator.message` FleetEvent payload that notifies a lane its task
|
|
39
|
+
* list changed. Pure (no I/O) so it is unit-testable; notifyLaneOfTaskChange
|
|
40
|
+
* posts it. slotName "cross-pane" is REQUIRED — it is the watchEvents bypass for
|
|
41
|
+
* events not tied to a fleet_agent_runs row. orchestratorSessionId is the TARGET
|
|
42
|
+
* lane pane (the SSE subscriber filter). The message is single-line (the
|
|
43
|
+
* <channel> tag renders it inline).
|
|
44
|
+
*/
|
|
45
|
+
export interface TaskChangedEvent {
|
|
46
|
+
type: "orchestrator.message";
|
|
47
|
+
message: string;
|
|
48
|
+
fromOrchestratorId: string;
|
|
49
|
+
runId: string;
|
|
50
|
+
slotName: "cross-pane";
|
|
51
|
+
ts: number;
|
|
52
|
+
orchestratorSessionId: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function buildTaskChangedEvent(lanePane: string, taskId: string, now?: number): TaskChangedEvent;
|
|
55
|
+
/**
|
|
56
|
+
* Publish a "your task list changed" notification to the owning lane's pane.
|
|
57
|
+
* The pane identity is the stable tmux id ("tmux:%42") every CLI relay/plugin
|
|
58
|
+
* receives for. `taskId` is carried in the message so the recipient can re-read
|
|
59
|
+
* just that task. Fire-and-forget.
|
|
60
|
+
*/
|
|
61
|
+
export declare function notifyLaneOfTaskChange(lanePane: string, taskId: string): Promise<void>;
|
|
@@ -3,60 +3,42 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
* Unit tests for the pure
|
|
7
|
-
*
|
|
6
|
+
* Unit tests for the pure event-payload builder in task-sync-dispatch. The HTTP
|
|
7
|
+
* POST + registry resolution are side-effectful (covered end-to-end by the
|
|
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.
|
|
8
11
|
*/
|
|
9
12
|
import { describe, it, expect } from "bun:test";
|
|
10
|
-
import {
|
|
13
|
+
import { buildTaskChangedEvent } from "./task-sync-dispatch.ts";
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
expect(comment).not.toHaveProperty("pane");
|
|
24
|
-
});
|
|
15
|
+
describe("task-sync buildTaskChangedEvent", () => {
|
|
16
|
+
it("is an orchestrator.message addressed to the owning lane pane", () => {
|
|
17
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
18
|
+
expect(e.type).toBe("orchestrator.message");
|
|
19
|
+
expect(e.orchestratorSessionId).toBe("tmux:%5");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("uses slotName cross-pane so watchEvents bypasses the fleet-run-existence check", () => {
|
|
23
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
24
|
+
expect(e.slotName).toBe("cross-pane");
|
|
25
|
+
});
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
});
|
|
27
|
+
it("carries the task id in the message so the recipient can re-read just it", () => {
|
|
28
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
29
|
+
expect(e.message).toContain("task-7");
|
|
30
|
+
expect(e.message).toContain("readTaskList");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("produces a single-line message (the <channel> tag renders it inline)", () => {
|
|
34
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
35
|
+
expect(e.message.includes("\n")).toBe(false);
|
|
36
|
+
});
|
|
44
37
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
});
|
|
38
|
+
it("stamps the system sender + a deterministic runId + the provided epoch-ms ts", () => {
|
|
39
|
+
const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
|
|
40
|
+
expect(e.fromOrchestratorId).toBe("task-sync");
|
|
41
|
+
expect(e.runId).toBe("task-sync-task-7-1700000000000");
|
|
42
|
+
expect(e.ts).toBe(1700000000000);
|
|
62
43
|
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* DB→AGENT dispatch for task-sync — notifies a lane's live pane that its
|
|
7
|
+
* work_items task list changed out-of-band (operator toggled a subtask in the
|
|
8
|
+
* projects UI, another lane edited a shared task). The pane's adapter then
|
|
9
|
+
* re-reads (fn.taskSync.readTaskList) and updates its native list.
|
|
10
|
+
*
|
|
11
|
+
* MECHANISM — reuses the existing CLI-agnostic channel delivery, no new
|
|
12
|
+
* transport: a POST to scala-channel's fleet-events publish entry
|
|
13
|
+
* (POST /api/fleet/events — @teamscala/channel/fleet-events-publish) carrying an
|
|
14
|
+
* `orchestrator.message` FleetEvent with slotName "cross-pane". That slot is
|
|
15
|
+
* the watchEvents bypass for events NOT tied to a fleet_agent_runs row
|
|
16
|
+
* (FleetEventOrchestratorMessageSchema comment) — exactly the system-originated
|
|
17
|
+
* case. scala-channel re-emits onto its eventBus and streamFleetEvents delivers
|
|
18
|
+
* to the subscriber filtered by orchestratorSessionId (the target lane pane).
|
|
19
|
+
* Both the Claude Code relay and the OpenCode scala-hooks plugin subscribe to
|
|
20
|
+
* that SSE stream for their own pane identity and inject each event natively —
|
|
21
|
+
* so ONE publish reaches every CLI that has a live projection, no per-CLI
|
|
22
|
+
* dispatch code here.
|
|
23
|
+
*
|
|
24
|
+
* NOT the dispatch entry (POST /api/channel/send): that path requires a real
|
|
25
|
+
* sender pane identity (channel-sender-identity-validated anti-spoof) and so
|
|
26
|
+
* cannot carry a system-originated notification. The publish path is
|
|
27
|
+
* internal-trusted (localhost super-admin) and accepts a synthetic
|
|
28
|
+
* fromOrchestratorId — the correct path for a system→pane message.
|
|
29
|
+
*
|
|
30
|
+
* scala-channel's address is resolved from its service_routing row (local_port)
|
|
31
|
+
* — never hardcoded (service-ports-from-registry). Fire-and-forget: a dispatch
|
|
32
|
+
* failure (scala-channel down, port unresolvable) is logged and swallowed; it
|
|
33
|
+
* must never block the work_items write that triggered it, and the lane's
|
|
34
|
+
* SessionStart/UserPromptSubmit backstop picks up the change on its next turn
|
|
35
|
+
* regardless.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
39
|
+
import { ServiceRoutingConfigSchema } from "@teamscala/db-validation/registry-schemas/service-routing";
|
|
40
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
41
|
+
|
|
42
|
+
let cachedChannelBaseUrl: string | null | undefined;
|
|
43
|
+
|
|
44
|
+
async function resolveChannelBaseUrl(): Promise<string | null> {
|
|
45
|
+
if (cachedChannelBaseUrl !== undefined) return cachedChannelBaseUrl;
|
|
46
|
+
try {
|
|
47
|
+
const row = await loadRegistryConfig("service_routing", "scala-channel", ServiceRoutingConfigSchema);
|
|
48
|
+
const port = row?.local_port;
|
|
49
|
+
cachedChannelBaseUrl = port ? `http://127.0.0.1:${port}` : null;
|
|
50
|
+
} catch {
|
|
51
|
+
cachedChannelBaseUrl = null;
|
|
52
|
+
}
|
|
53
|
+
return cachedChannelBaseUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The system sender identity stamped on fromOrchestratorId. */
|
|
57
|
+
const FROM_ORCHESTRATOR_ID = "task-sync";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The `orchestrator.message` FleetEvent payload that notifies a lane its task
|
|
61
|
+
* list changed. Pure (no I/O) so it is unit-testable; notifyLaneOfTaskChange
|
|
62
|
+
* posts it. slotName "cross-pane" is REQUIRED — it is the watchEvents bypass for
|
|
63
|
+
* events not tied to a fleet_agent_runs row. orchestratorSessionId is the TARGET
|
|
64
|
+
* lane pane (the SSE subscriber filter). The message is single-line (the
|
|
65
|
+
* <channel> tag renders it inline).
|
|
66
|
+
*/
|
|
67
|
+
export interface TaskChangedEvent {
|
|
68
|
+
type: "orchestrator.message";
|
|
69
|
+
message: string;
|
|
70
|
+
fromOrchestratorId: string;
|
|
71
|
+
runId: string;
|
|
72
|
+
slotName: "cross-pane";
|
|
73
|
+
ts: number;
|
|
74
|
+
orchestratorSessionId: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildTaskChangedEvent(lanePane: string, taskId: string, now: number = Date.now()): TaskChangedEvent {
|
|
78
|
+
return {
|
|
79
|
+
type: "orchestrator.message",
|
|
80
|
+
message: `TaskSync: your work_items task list changed externally (task ${taskId}). Re-read via fn.taskSync.readTaskList and update your todo list.`,
|
|
81
|
+
fromOrchestratorId: FROM_ORCHESTRATOR_ID,
|
|
82
|
+
runId: `task-sync-${taskId}-${now}`,
|
|
83
|
+
slotName: "cross-pane",
|
|
84
|
+
ts: now,
|
|
85
|
+
orchestratorSessionId: lanePane,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Publish a "your task list changed" notification to the owning lane's pane.
|
|
91
|
+
* The pane identity is the stable tmux id ("tmux:%42") every CLI relay/plugin
|
|
92
|
+
* receives for. `taskId` is carried in the message so the recipient can re-read
|
|
93
|
+
* just that task. Fire-and-forget.
|
|
94
|
+
*/
|
|
95
|
+
export async function notifyLaneOfTaskChange(
|
|
96
|
+
lanePane: string,
|
|
97
|
+
taskId: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const log = getAppLogger();
|
|
100
|
+
if (!lanePane) return;
|
|
101
|
+
const base = await resolveChannelBaseUrl();
|
|
102
|
+
if (!base) {
|
|
103
|
+
log.warn("[task-sync] notifyLaneOfTaskChange: scala-channel address unresolvable", { taskId });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const payload = buildTaskChangedEvent(lanePane, taskId);
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(`${base}/api/fleet/events`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify(payload),
|
|
112
|
+
signal: AbortSignal.timeout(3000),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
log.warn("[task-sync] notifyLaneOfTaskChange: non-2xx from scala-channel", {
|
|
116
|
+
taskId, status: res.status,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log.warn("[task-sync] notifyLaneOfTaskChange: POST failed", {
|
|
121
|
+
taskId, error: err instanceof Error ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Lane RESOLUTION for task-sync: given a session or a pane, which work_items
|
|
7
|
+
* task IS this lane, and what is it called.
|
|
8
|
+
*
|
|
9
|
+
* Split out of task-sync-router.ts, which had reached 998 of its 1000-line
|
|
10
|
+
* budget — two lines from frozen. That is not tidiness: the budget is a commit
|
|
11
|
+
* gate, so at 998 lines ANY change to task-sync was blocked for every lane, and
|
|
12
|
+
* the four-line fix that needed to land could not. The router header records
|
|
13
|
+
* being split once before for exactly this reason; it grew back.
|
|
14
|
+
*
|
|
15
|
+
* The seam matches the one that split the projection. Everything here answers
|
|
16
|
+
* "which lane, called what" from an identity, and none of it touches the ORPC
|
|
17
|
+
* surface or decides what a task list should become — which is why these move
|
|
18
|
+
* together rather than one at a time: resolveLaneTask alone has seven callers.
|
|
19
|
+
*
|
|
20
|
+
* CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
|
|
21
|
+
* candidate. Checked against the 14 platform singletons and the package
|
|
22
|
+
* catalogue; the doctrine test fails plainly, because every export is bound to
|
|
23
|
+
* the work_items schema and to task-sync own kind_data.lane convention, so no
|
|
24
|
+
* unrelated app could install this unmodified.
|
|
25
|
+
*/
|
|
26
|
+
import type { Prisma, WorkItem } from "./task-sync-router.ts";
|
|
27
|
+
export declare function resolveLaneWorkName(prisma: Prisma, pane: string): Promise<string | null>;
|
|
28
|
+
export declare function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkItem | null>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a lane's task by its AUTHORITATIVE work_item id — the id the spawn
|
|
31
|
+
* minted (createBriefTask) and exported into the pane as SCALA_LANE_WORK_ITEM.
|
|
32
|
+
*
|
|
33
|
+
* WHY this exists alongside the session resolution: readAssignedBrief used to
|
|
34
|
+
* resolve ONLY by kind_data.lane.session, which is written by the lane's OWN
|
|
35
|
+
* bindLane hook at boot. That bind is a fire-and-forget POST racing the brief
|
|
36
|
+
* read, so a fleet lane whose bind had not yet commit read "no task" and the
|
|
37
|
+
* self-brief hook silently exited 0 — absent-input-reads-as-success at the
|
|
38
|
+
* dispatch boundary (a lane booted, was told its brief was loaded, had none,
|
|
39
|
+
* ran nothing, and was archived having done nothing — measured 2026-08-02).
|
|
40
|
+
*
|
|
41
|
+
* The work_item id is known to the spawn before the lane even starts
|
|
42
|
+
* (mintBriefTask returns it; the spawn REFUSES if minting fails for a capture
|
|
43
|
+
* CLI), so resolving by it CANNOT race: the row exists because the spawn
|
|
44
|
+
* created it. The session bind stays for task-list writes (upsertFromTaskList)
|
|
45
|
+
* — it is no longer load-bearing for brief delivery. Any status is matched
|
|
46
|
+
* (active OR archived) because the brief is the brief regardless of the run's
|
|
47
|
+
* state — closing a run archives the task, not the brief.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveTaskById(prisma: Prisma, workItemId: string): Promise<WorkItem | null>;
|