@teamscala/service-boot 0.23.335 → 0.23.337
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 +10 -6
- package/src/auth-gate.ts +2 -3
- package/src/build-client-assets.test.ts +1 -1
- package/src/build-client-assets.ts +1 -1
- package/src/fn-routers/orchestrator-registration-router.d.ts +17 -3
- package/src/fn-routers/orchestrator-registration-router.ts +17 -3
- package/src/fn-routers/orchestrator-registration.live.test.ts +23 -2
- package/src/fn-routers/orchestrator-registration.test.ts +23 -0
- package/src/fn-routers/task-sync-assign.test.ts +1 -0
- package/src/fn-routers/task-sync-bind.test.ts +18 -18
- package/src/fn-routers/task-sync-brief.test.ts +2 -3
- package/src/fn-routers/task-sync-dispatch.d.ts +40 -54
- package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
- package/src/fn-routers/task-sync-dispatch.ts +57 -100
- package/src/fn-routers/task-sync-lane.test.ts +36 -0
- package/src/fn-routers/task-sync-lane.ts +8 -19
- package/src/fn-routers/task-sync-projection.d.ts +23 -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-router.d.ts +30 -23
- package/src/fn-routers/task-sync-router.test.ts +14 -2
- package/src/fn-routers/task-sync-router.ts +72 -114
- package/src/handlers/sweep-host-cache.d.ts +21 -1
- package/src/handlers/sweep-host-cache.test.ts +85 -0
- package/src/handlers/sweep-host-cache.ts +118 -4
- package/src/mount-orpc-router.ts +1 -1
- package/src/resolve-brand-favicon.d.ts +1 -1
- package/src/resolve-brand-favicon.ts +1 -1
package/package.json
CHANGED
|
@@ -6,19 +6,17 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.337",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"files": [
|
|
12
12
|
"src"
|
|
13
13
|
],
|
|
14
|
-
"peerDependencies": {},
|
|
15
14
|
"dependencies": {
|
|
16
15
|
"@teamscala/agreement-generation": "*",
|
|
17
16
|
"@teamscala/api-resend": "*",
|
|
18
17
|
"@teamscala/auth": "*",
|
|
19
18
|
"@teamscala/cache": "*",
|
|
20
19
|
"@teamscala/channel": "*",
|
|
21
|
-
"@teamscala/chat-completion": "*",
|
|
22
20
|
"@teamscala/db": "*",
|
|
23
21
|
"@teamscala/db-validation": "*",
|
|
24
22
|
"@teamscala/document-signing": "*",
|
|
@@ -50,9 +48,7 @@
|
|
|
50
48
|
"devDependencies": {
|
|
51
49
|
"bun-types": "*",
|
|
52
50
|
"@happy-dom/global-registrator": "*",
|
|
53
|
-
"@testing-library/react": "*"
|
|
54
|
-
"hono": "*",
|
|
55
|
-
"pg-boss": "*"
|
|
51
|
+
"@testing-library/react": "*"
|
|
56
52
|
},
|
|
57
53
|
"exports": {
|
|
58
54
|
"./agreement-gate": {
|
|
@@ -1087,5 +1083,13 @@
|
|
|
1087
1083
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
1088
1084
|
"publishConfig": {
|
|
1089
1085
|
"registry": "https://registry.npmjs.org"
|
|
1086
|
+
},
|
|
1087
|
+
"peerDependencies": {
|
|
1088
|
+
"@teamscala/chat-completion": "*"
|
|
1089
|
+
},
|
|
1090
|
+
"peerDependenciesMeta": {
|
|
1091
|
+
"@teamscala/chat-completion": {
|
|
1092
|
+
"optional": true
|
|
1093
|
+
}
|
|
1090
1094
|
}
|
|
1091
1095
|
}
|
package/src/auth-gate.ts
CHANGED
|
@@ -114,12 +114,11 @@ const PUBLIC_INFRA_PATHS = new Set([
|
|
|
114
114
|
// the other dev-host infra reads.
|
|
115
115
|
"/api/fleet/claude-sessions",
|
|
116
116
|
// /api/fleet/ingest-event receives parsed lane-action events
|
|
117
|
-
// (message.part.updated etc.) from the cli-session
|
|
117
|
+
// (message.part.updated etc.) from the cli-session transcript-tail — a
|
|
118
118
|
// dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
|
|
119
119
|
// like the lane-event hook. Without this exemption the page auth-gate 302s
|
|
120
120
|
// every POST and the lane's tool/text action stream never reaches the
|
|
121
|
-
// ingestEvent pipeline
|
|
122
|
-
// shows only lifecycle lines, never the per-tab action feed). A first-class
|
|
121
|
+
// ingestEvent pipeline. A first-class
|
|
123
122
|
// api_route (sibling to lane-event); security is the handler's loopback gate
|
|
124
123
|
// on the platform's x-caller-remote-address (set by register-api-routes).
|
|
125
124
|
"/api/fleet/ingest-event",
|
|
@@ -167,7 +167,7 @@ describe("computeInputFingerprint", () => {
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
// Regression guard for the 2026-07-12 scala-agents stale-bundle bug. A boot
|
|
170
|
-
// that builds MID-INSTALL (
|
|
170
|
+
// that builds MID-INSTALL (dep-sync restarts after writing the new
|
|
171
171
|
// bun.lock but before node_modules extraction finishes) bakes OLD @teamscala/*
|
|
172
172
|
// JS while the lock-derived fingerprint records "current" — so every later
|
|
173
173
|
// restart recomputes the same fingerprint, skips the rebuild, and serves the
|
|
@@ -84,7 +84,7 @@ export async function readClientBuildStatus(
|
|
|
84
84
|
|
|
85
85
|
// Single shared host lock serialising the heavy boot-time client build across
|
|
86
86
|
// ALL kind:ui services (client-asset-builds-serialize-via-lock). A publish of a
|
|
87
|
-
// widely-consumed package makes the
|
|
87
|
+
// widely-consumed package makes the dep-sync restart every consumer at
|
|
88
88
|
// once; without this lock each one rebuilds its hydrate bundle + Tailwind CSS
|
|
89
89
|
// simultaneously and N concurrent builds saturate the host (load 36, each ~15s
|
|
90
90
|
// build starved to ~10min, services offline — 2026-06-13). The build sibling of
|
|
@@ -13,12 +13,24 @@ declare const input: v.ObjectSchema<{
|
|
|
13
13
|
readonly runtime_slug: v.StringSchema<undefined>;
|
|
14
14
|
}, undefined>;
|
|
15
15
|
export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
|
|
16
|
+
export declare function buildOrchestratorSpawnPayload(input: {
|
|
17
|
+
workItemId: string;
|
|
18
|
+
launchCommentId: string;
|
|
19
|
+
cliSessionId: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
}): {
|
|
22
|
+
workItemId: string;
|
|
23
|
+
launchCommentId: string;
|
|
24
|
+
cliSessionId: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
};
|
|
16
27
|
export declare function ensureOrchestratorRegistration(request: OrchestratorRegistrationRequest, context: Context): Promise<{
|
|
17
28
|
projectId: string;
|
|
18
29
|
taskId: string;
|
|
19
30
|
sessionId: string;
|
|
31
|
+
launchCommentId: string;
|
|
20
32
|
runtime: string;
|
|
21
|
-
|
|
33
|
+
slug: string | undefined;
|
|
22
34
|
}>;
|
|
23
35
|
export declare const orchestratorRegistrationRouter: {
|
|
24
36
|
ensure: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
|
|
@@ -33,14 +45,16 @@ export declare const orchestratorRegistrationRouter: {
|
|
|
33
45
|
projectId: string;
|
|
34
46
|
taskId: string;
|
|
35
47
|
sessionId: string;
|
|
48
|
+
launchCommentId: string;
|
|
36
49
|
runtime: string;
|
|
37
|
-
|
|
50
|
+
slug: string | undefined;
|
|
38
51
|
}, {
|
|
39
52
|
projectId: string;
|
|
40
53
|
taskId: string;
|
|
41
54
|
sessionId: string;
|
|
55
|
+
launchCommentId: string;
|
|
42
56
|
runtime: string;
|
|
43
|
-
|
|
57
|
+
slug: string | undefined;
|
|
44
58
|
}>, Record<never, never>, Record<never, never>>;
|
|
45
59
|
};
|
|
46
60
|
export {};
|
|
@@ -19,6 +19,20 @@ const registryConfig = v.looseObject({
|
|
|
19
19
|
|
|
20
20
|
export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
|
|
21
21
|
|
|
22
|
+
export function buildOrchestratorSpawnPayload(input: {
|
|
23
|
+
workItemId: string;
|
|
24
|
+
launchCommentId: string;
|
|
25
|
+
cliSessionId: string;
|
|
26
|
+
slug: string;
|
|
27
|
+
}) {
|
|
28
|
+
return {
|
|
29
|
+
workItemId: input.workItemId,
|
|
30
|
+
launchCommentId: input.launchCommentId,
|
|
31
|
+
cliSessionId: input.cliSessionId,
|
|
32
|
+
slug: input.slug,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
export async function ensureOrchestratorRegistration(
|
|
23
37
|
request: OrchestratorRegistrationRequest,
|
|
24
38
|
context: Context,
|
|
@@ -49,10 +63,10 @@ export async function ensureOrchestratorRegistration(
|
|
|
49
63
|
update: { picker_option_slug: runtime.picker_option_slug, runtime_slug: request.runtime_slug, model: runtime.model, closed_at: null, lifecycle_state: "registering" },
|
|
50
64
|
});
|
|
51
65
|
const startupBrief = `You are the permanent Orchestrator for developer project “${project.title}” (${project.id}). Coordinate all work through the canonical work_items and comments surfaces. Every tab is a child task, every prompt and reply is a comment, and cli_sessions is runtime identity only. Dispatch implementation through the fleet and reconcile durable outcomes; never create a parallel task, message, or session source.`;
|
|
52
|
-
await tx.comments.upsert({ where: { work_item_id_system_key: { work_item_id: task.id, system_key: "orchestrator-startup" } }, update: {}, create: { work_item_id: task.id, content: startupBrief, system_key: "orchestrator-startup",
|
|
66
|
+
const launchComment = await tx.comments.upsert({ where: { work_item_id_system_key: { work_item_id: task.id, system_key: "orchestrator-startup" } }, update: { source_cli_session_id: session.id, sender_role: "orchestrator", sender_name: "Orchestrator" }, create: { work_item_id: task.id, content: startupBrief, system_key: "orchestrator-startup", source_cli_session_id: session.id, sender_role: "orchestrator", sender_name: "Orchestrator" } });
|
|
53
67
|
await tx.cli_session_transitions.upsert({ where: { session_id_idempotency_key: { session_id: session.id, idempotency_key: `register:${project.id}` } }, create: { session_id: session.id, idempotency_key: `register:${project.id}`, from_state: null, to_state: "registering", trigger_event: "atomic-registration", metadata_json: { work_item_id: task.id } }, update: {} });
|
|
54
|
-
await tx.host_commands.upsert({ where: { command_idempotency_key: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}` } }, update: {}, create: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}`, payload: { workItemId: task.id,
|
|
55
|
-
return { projectId: project.id, taskId: task.id, sessionId: session.id, runtime: request.runtime_slug,
|
|
68
|
+
await tx.host_commands.upsert({ where: { command_idempotency_key: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}` } }, update: {}, create: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}`, payload: buildOrchestratorSpawnPayload({ workItemId: task.id, launchCommentId: launchComment.id, cliSessionId: session.id, slug: runtime.picker_option_slug }), originator: { role: "orchestrator", session_id: session.id } } });
|
|
69
|
+
return { projectId: project.id, taskId: task.id, sessionId: session.id, launchCommentId: launchComment.id, runtime: request.runtime_slug, slug: runtime.picker_option_slug };
|
|
56
70
|
});
|
|
57
71
|
}
|
|
58
72
|
|
|
@@ -38,10 +38,31 @@ describe.skipIf(!live)("canonical Orchestrator registration — live database",
|
|
|
38
38
|
const sessionId = results[0]?.sessionId;
|
|
39
39
|
expect(await db.prisma.work_items.count({ where: { id: projectId, parent: { kind: "initiative", kind_data: { path: ["tree"], equals: "agent_work" } } } })).toBe(1);
|
|
40
40
|
expect(await db.prisma.work_items.count({ where: { id: taskId, parent_id: projectId, kind: "task", title: "Orchestrator" } })).toBe(1);
|
|
41
|
-
|
|
41
|
+
const startupComments = await db.prisma.comments.findMany({
|
|
42
|
+
where: { work_item_id: taskId, system_key: "orchestrator-startup" },
|
|
43
|
+
select: { id: true, source_cli_session_id: true, sender_role: true, sender_name: true },
|
|
44
|
+
});
|
|
45
|
+
expect(startupComments).toHaveLength(1);
|
|
46
|
+
expect(startupComments[0]).toMatchObject({
|
|
47
|
+
id: results[0]?.launchCommentId,
|
|
48
|
+
source_cli_session_id: sessionId,
|
|
49
|
+
sender_role: "orchestrator",
|
|
50
|
+
sender_name: "Orchestrator",
|
|
51
|
+
});
|
|
42
52
|
expect(await db.prisma.cli_sessions.count({ where: { id: sessionId, work_item_id: taskId, role: "orchestrator", closed_at: null } })).toBe(1);
|
|
43
53
|
expect(await db.prisma.cli_session_transitions.count({ where: { session_id: sessionId, idempotency_key: `register:${projectId}` } })).toBe(1);
|
|
44
|
-
|
|
54
|
+
const spawnIntents = await db.prisma.host_commands.findMany({
|
|
55
|
+
where: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${projectId}` },
|
|
56
|
+
select: { payload: true },
|
|
57
|
+
});
|
|
58
|
+
expect(spawnIntents).toHaveLength(1);
|
|
59
|
+
expect(spawnIntents[0]?.payload).toMatchObject({
|
|
60
|
+
workItemId: taskId,
|
|
61
|
+
launchCommentId: results[0]?.launchCommentId,
|
|
62
|
+
cliSessionId: sessionId,
|
|
63
|
+
slug: results[0]?.slug,
|
|
64
|
+
});
|
|
65
|
+
expect(Object.keys(spawnIntents[0]?.payload as Record<string, unknown>).sort()).toEqual(["cliSessionId", "launchCommentId", "slug", "workItemId"]);
|
|
45
66
|
} finally {
|
|
46
67
|
await db.disconnect();
|
|
47
68
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { buildOrchestratorSpawnPayload } from "./orchestrator-registration-router.ts";
|
|
3
|
+
|
|
4
|
+
describe("orchestrator spawn intent", () => {
|
|
5
|
+
it("uses the generated Rust slug contract and carries no alternate launch input", () => {
|
|
6
|
+
const payload = buildOrchestratorSpawnPayload({
|
|
7
|
+
workItemId: "task-1",
|
|
8
|
+
launchCommentId: "comment-1",
|
|
9
|
+
cliSessionId: "session-1",
|
|
10
|
+
slug: "codex-sol-acp-client",
|
|
11
|
+
});
|
|
12
|
+
expect(payload).toEqual({
|
|
13
|
+
workItemId: "task-1",
|
|
14
|
+
launchCommentId: "comment-1",
|
|
15
|
+
cliSessionId: "session-1",
|
|
16
|
+
slug: "codex-sol-acp-client",
|
|
17
|
+
});
|
|
18
|
+
expect(payload).not.toHaveProperty("pickerOptionSlug");
|
|
19
|
+
expect(payload).not.toHaveProperty("initialPrompt");
|
|
20
|
+
expect(payload).not.toHaveProperty("windowName");
|
|
21
|
+
expect(Object.keys(payload).sort()).toEqual(["cliSessionId", "launchCommentId", "slug", "workItemId"]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -397,6 +397,7 @@ describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-bri
|
|
|
397
397
|
const out = buildAssignmentSpawnPayload(task, { slug: "cc-glm52" });
|
|
398
398
|
expect("initialPrompt" in out.payload).toBe(false);
|
|
399
399
|
expect(out.payload).not.toHaveProperty("initialPrompt");
|
|
400
|
+
expect("cliSessionId" in out.payload).toBe(false);
|
|
400
401
|
});
|
|
401
402
|
|
|
402
403
|
it("omits optional spawn fields when not supplied (no undefined keys)", () => {
|
|
@@ -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
|
});
|
|
@@ -112,9 +112,8 @@ describe("readAssignedBrief reads the FIRST launch comment (source contract)", (
|
|
|
112
112
|
|
|
113
113
|
describe("readFirstTaskComment is a single deterministic launch-comment read", () => {
|
|
114
114
|
it("returns null when the prisma client has no comments model (feature-detect)", async () => {
|
|
115
|
-
// `comments` is a universal shared model
|
|
116
|
-
//
|
|
117
|
-
// brief" rather than throwing.
|
|
115
|
+
// `comments` is a universal shared model; a client without it degrades to
|
|
116
|
+
// "no brief" rather than throwing.
|
|
118
117
|
const out = await readFirstTaskComment({} as never, "task-1");
|
|
119
118
|
expect(out).toBe(null);
|
|
120
119
|
});
|
|
@@ -3,59 +3,45 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
* DB→AGENT
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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).
|
|
6
|
+
* DB→AGENT task projection emission. A work_items change is delivered by one
|
|
7
|
+
* canonical comments row addressed to the active cli_sessions runtime; the
|
|
8
|
+
* database emission wakes that runtime adapter, which re-reads readTaskList.
|
|
9
|
+
* This module owns no pane transport, terminal fallback, or second message
|
|
10
|
+
* queue.
|
|
44
11
|
*/
|
|
45
|
-
export interface
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
orchestratorSessionId: string;
|
|
12
|
+
export interface TaskChangeComment {
|
|
13
|
+
work_item_id: string;
|
|
14
|
+
content: string;
|
|
15
|
+
system_key: string;
|
|
16
|
+
source_cli_session_id: string;
|
|
17
|
+
sender_role: "system";
|
|
18
|
+
sender_name: "TaskSync";
|
|
53
19
|
}
|
|
54
|
-
export declare function
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
20
|
+
export declare function buildTaskChangeComment(taskId: string, sessionId: string, eventKey?: string): TaskChangeComment;
|
|
21
|
+
export interface TaskDispatchPrisma {
|
|
22
|
+
cli_sessions: {
|
|
23
|
+
findFirst(args: unknown): Promise<{
|
|
24
|
+
id: string;
|
|
25
|
+
} | null>;
|
|
26
|
+
};
|
|
27
|
+
comments: {
|
|
28
|
+
upsert(args: {
|
|
29
|
+
where: {
|
|
30
|
+
work_item_id_system_key: {
|
|
31
|
+
work_item_id: string;
|
|
32
|
+
system_key: string;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
create: TaskChangeComment;
|
|
36
|
+
update: Record<string, never>;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
id: string;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Write the durable task-change comment for the active runtime, if present. */
|
|
43
|
+
export declare function notifyLaneOfTaskChange(prisma: TaskDispatchPrisma, taskId: string, eventKey?: string): Promise<{
|
|
44
|
+
delivered: boolean;
|
|
45
|
+
sessionId: string | null;
|
|
46
|
+
commentId: string | null;
|
|
47
|
+
}>;
|
|
@@ -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
|
-
});
|