@teamscala/service-boot 0.23.332 → 0.23.334
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 +9 -1
- package/src/fn-routers/index.ts +2 -0
- package/src/fn-routers/orchestrator-registration-router.d.ts +46 -0
- package/src/fn-routers/orchestrator-registration-router.ts +63 -0
- package/src/fn-routers/orchestrator-registration.live.test.ts +49 -0
- package/src/fn-routers/task-sync-assign.d.ts +2 -0
- package/src/fn-routers/task-sync-assign.test.ts +1 -1
- package/src/fn-routers/task-sync-assign.ts +4 -4
- package/src/fn-routers/task-sync-brief.d.ts +4 -1
- package/src/fn-routers/task-sync-brief.test.ts +10 -5
- package/src/fn-routers/task-sync-brief.ts +6 -4
- package/src/fn-routers/task-sync-router.d.ts +4 -0
- package/src/serve/stamp-transport-identity.d.ts +7 -0
- package/src/serve/stamp-transport-identity.test.ts +24 -0
- package/src/serve/stamp-transport-identity.ts +14 -2
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.334",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"files": [
|
|
12
12
|
"src"
|
|
@@ -323,6 +323,14 @@
|
|
|
323
323
|
"types": "./src/fn-routers/index.d.ts",
|
|
324
324
|
"default": "./src/fn-routers/index.ts"
|
|
325
325
|
},
|
|
326
|
+
"./fn-routers/orchestrator-registration-router": {
|
|
327
|
+
"types": "./src/fn-routers/orchestrator-registration-router.d.ts",
|
|
328
|
+
"default": "./src/fn-routers/orchestrator-registration-router.ts"
|
|
329
|
+
},
|
|
330
|
+
"./fn-routers/orchestrator-registration-router.ts": {
|
|
331
|
+
"types": "./src/fn-routers/orchestrator-registration-router.d.ts",
|
|
332
|
+
"default": "./src/fn-routers/orchestrator-registration-router.ts"
|
|
333
|
+
},
|
|
326
334
|
"./fn-routers/panel-data-router": {
|
|
327
335
|
"types": "./src/fn-routers/panel-data-router.d.ts",
|
|
328
336
|
"default": "./src/fn-routers/panel-data-router.ts"
|
package/src/fn-routers/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { tasksRouter } from "./tasks-router.ts";
|
|
|
37
37
|
import { userPreferencesRouter } from "./user-preferences-router.ts";
|
|
38
38
|
import { userPreferencesRouter as portalPreferencesRouter } from "../page-server-orpc/user-preferences-router.ts";
|
|
39
39
|
import { workItemsRouter } from "./work-items-router.ts";
|
|
40
|
+
import { orchestratorRegistrationRouter } from "./orchestrator-registration-router.ts";
|
|
40
41
|
|
|
41
42
|
export function registerFnRouters(): void {
|
|
42
43
|
registerCustomRouter("fn", {
|
|
@@ -53,6 +54,7 @@ export function registerFnRouters(): void {
|
|
|
53
54
|
tasks: tasksRouter,
|
|
54
55
|
taskSync: taskSyncRouter,
|
|
55
56
|
workItems: workItemsRouter,
|
|
57
|
+
orchestratorRegistration: orchestratorRegistrationRouter,
|
|
56
58
|
userPreferences: userPreferencesRouter,
|
|
57
59
|
portalPreferences: portalPreferencesRouter,
|
|
58
60
|
plans: plansRouter,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* One transaction for the permanent Orchestrator registration. Database
|
|
7
|
+
* uniqueness is the correctness boundary; every identity row is upserted
|
|
8
|
+
* inside one transaction and callers cannot supply a project/session id.
|
|
9
|
+
*/
|
|
10
|
+
import { type Context } from "./auth-router.ts";
|
|
11
|
+
import * as v from "valibot";
|
|
12
|
+
declare const input: v.ObjectSchema<{
|
|
13
|
+
readonly runtime_slug: v.StringSchema<undefined>;
|
|
14
|
+
}, undefined>;
|
|
15
|
+
export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
|
|
16
|
+
export declare function ensureOrchestratorRegistration(request: OrchestratorRegistrationRequest, context: Context): Promise<{
|
|
17
|
+
projectId: string;
|
|
18
|
+
taskId: string;
|
|
19
|
+
sessionId: string;
|
|
20
|
+
runtime: string;
|
|
21
|
+
pickerOptionSlug: string | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
export declare const orchestratorRegistrationRouter: {
|
|
24
|
+
ensure: 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 runtime_slug: v.StringSchema<undefined>;
|
|
32
|
+
}, undefined>, import("@orpc/contract").Schema<{
|
|
33
|
+
projectId: string;
|
|
34
|
+
taskId: string;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
runtime: string;
|
|
37
|
+
pickerOptionSlug: string | undefined;
|
|
38
|
+
}, {
|
|
39
|
+
projectId: string;
|
|
40
|
+
taskId: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
runtime: string;
|
|
43
|
+
pickerOptionSlug: string | undefined;
|
|
44
|
+
}>, Record<never, never>, Record<never, never>>;
|
|
45
|
+
};
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* One transaction for the permanent Orchestrator registration. Database
|
|
7
|
+
* uniqueness is the correctness boundary; every identity row is upserted
|
|
8
|
+
* inside one transaction and callers cannot supply a project/session id.
|
|
9
|
+
*/
|
|
10
|
+
import { authed, type Context } from "./auth-router.ts";
|
|
11
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
12
|
+
import type { PrismaClient } from "@teamscala/db/client";
|
|
13
|
+
import * as v from "valibot";
|
|
14
|
+
|
|
15
|
+
const input = v.object({ runtime_slug: v.string() });
|
|
16
|
+
const registryConfig = v.looseObject({
|
|
17
|
+
enabled: v.optional(v.boolean()), model: v.optional(v.string()), reasoning_effort: v.optional(v.string()), picker_option_slug: v.optional(v.string()),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
|
|
21
|
+
|
|
22
|
+
export async function ensureOrchestratorRegistration(
|
|
23
|
+
request: OrchestratorRegistrationRequest,
|
|
24
|
+
context: Context,
|
|
25
|
+
) {
|
|
26
|
+
const runtime = await loadRegistryConfig("agent_runtime", request.runtime_slug, registryConfig);
|
|
27
|
+
if (runtime.enabled === false || runtime.model !== "gpt-5.6-sol" || runtime.reasoning_effort !== "high" || !runtime.picker_option_slug) throw new Error("requested runtime is not the registry-backed Sol High Orchestrator runtime");
|
|
28
|
+
const user = context.user as { id: string; name?: string | null; displayName?: string | null; organisationId?: string | null };
|
|
29
|
+
const organisationId = context.orgId || user.organisationId;
|
|
30
|
+
if (!organisationId) throw new Error("Orchestrator registration requires an organisation");
|
|
31
|
+
return await (context.prisma as PrismaClient).$transaction(async (tx) => {
|
|
32
|
+
const initiative = await tx.work_items.findFirst({ where: { organisation_id: organisationId, kind: "initiative", kind_data: { path: ["tree"], equals: "agent_work" } } });
|
|
33
|
+
if (!initiative) throw new Error("agent-work initiative is not registered");
|
|
34
|
+
const developerName = user.displayName?.trim() || user.name?.trim() || "Developer";
|
|
35
|
+
const project = await tx.work_items.upsert({
|
|
36
|
+
where: { organisation_id_parent_id_assignee_id: { organisation_id: organisationId, parent_id: initiative.id, assignee_id: user.id } },
|
|
37
|
+
create: { kind: "project", parent_id: initiative.id, organisation_id: organisationId, title: `Developer session — ${developerName}`, owner_id: user.id, assignee_id: user.id, created_by: user.id, kind_data: { project_type: "developer_session" } },
|
|
38
|
+
update: { title: `Developer session — ${developerName}`, owner_id: user.id, assignee_id: user.id },
|
|
39
|
+
});
|
|
40
|
+
await tx.work_item_roles.upsert({ where: { work_item_id_user_id_role: { work_item_id: project.id, user_id: user.id, role: "assignee" } }, create: { work_item_id: project.id, user_id: user.id, role: "assignee", organisation_id: organisationId, created_by: user.id }, update: {} });
|
|
41
|
+
const task = await tx.work_items.upsert({
|
|
42
|
+
where: { parent_id_title: { parent_id: project.id, title: "Orchestrator" } },
|
|
43
|
+
create: { kind: "task", parent_id: project.id, organisation_id: organisationId, title: "Orchestrator", owner_id: user.id, assignee_id: user.id, created_by: user.id },
|
|
44
|
+
update: { owner_id: user.id, assignee_id: user.id },
|
|
45
|
+
});
|
|
46
|
+
const session = await tx.cli_sessions.upsert({
|
|
47
|
+
where: { work_item_id: task.id },
|
|
48
|
+
create: { authority: "operator", role: "orchestrator", cli_kind: "codex", picker_option_slug: runtime.picker_option_slug, runtime_slug: request.runtime_slug, provider: "codex", model: runtime.model, source_adapter: "orchestrator", label: "Orchestrator", lifecycle_state: "registering", host_route: {}, restore_metadata: {}, adapter_metadata: { project_id: project.id, work_item_id: task.id }, work_item_id: task.id },
|
|
49
|
+
update: { picker_option_slug: runtime.picker_option_slug, runtime_slug: request.runtime_slug, model: runtime.model, closed_at: null, lifecycle_state: "registering" },
|
|
50
|
+
});
|
|
51
|
+
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", user_id: user.id } });
|
|
53
|
+
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, runtimeSlug: request.runtime_slug, pickerOptionSlug: runtime.picker_option_slug, visible: true }, originator: { role: "orchestrator", session_id: session.id } } });
|
|
55
|
+
return { projectId: project.id, taskId: task.id, sessionId: session.id, runtime: request.runtime_slug, pickerOptionSlug: runtime.picker_option_slug };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const orchestratorRegistrationRouter = {
|
|
60
|
+
ensure: authed.input(input).handler(async ({ input: request, context }) => {
|
|
61
|
+
return ensureOrchestratorRegistration(request, context);
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Real database concurrency acceptance for the canonical developer-session
|
|
7
|
+
* registration. Run with PM_FLEET_LIVE_TEST=1 and a PM_FLEET_TEST_USER_ID
|
|
8
|
+
* (plus PM_FLEET_TEST_ORG_ID when the user has more than one membership).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import { createDbClient } from "@teamscala/db/create-db-client";
|
|
12
|
+
import { registerPrismaClient } from "@teamscala/db/prisma-registry";
|
|
13
|
+
import { ensureOrchestratorRegistration } from "./orchestrator-registration-router.ts";
|
|
14
|
+
import type { Context } from "./auth-router.ts";
|
|
15
|
+
|
|
16
|
+
const live = process.env.PM_FLEET_LIVE_TEST === "1";
|
|
17
|
+
|
|
18
|
+
describe.skipIf(!live)("canonical Orchestrator registration — live database", () => {
|
|
19
|
+
it("concurrent callers converge on one project, task, comment, runtime, transition, and spawn intent", async () => {
|
|
20
|
+
const userId = process.env.PM_FLEET_TEST_USER_ID;
|
|
21
|
+
if (!userId) throw new Error("PM_FLEET_TEST_USER_ID is required for the live concurrency acceptance");
|
|
22
|
+
const db = await createDbClient({ serviceName: "service-boot-pm-fleet-live", maxConnections: 10 });
|
|
23
|
+
try {
|
|
24
|
+
registerPrismaClient(db.prisma as never);
|
|
25
|
+
const user = await db.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { id: true, name: true } });
|
|
26
|
+
const organisationId = process.env.PM_FLEET_TEST_ORG_ID;
|
|
27
|
+
if (!organisationId) throw new Error("PM_FLEET_TEST_ORG_ID is required for the live concurrency acceptance");
|
|
28
|
+
const context: Context = { user: { id: user.id, name: user.name, organisationId }, orgId: organisationId, prisma: db.prisma, headers: new Headers() };
|
|
29
|
+
const results = await Promise.all(Array.from({ length: 8 }, () => ensureOrchestratorRegistration({ runtime_slug: "codex-acp" }, context)));
|
|
30
|
+
const projectIds = new Set(results.map((result) => result.projectId));
|
|
31
|
+
const taskIds = new Set(results.map((result) => result.taskId));
|
|
32
|
+
const sessionIds = new Set(results.map((result) => result.sessionId));
|
|
33
|
+
expect(projectIds.size).toBe(1);
|
|
34
|
+
expect(taskIds.size).toBe(1);
|
|
35
|
+
expect(sessionIds.size).toBe(1);
|
|
36
|
+
const projectId = results[0]?.projectId;
|
|
37
|
+
const taskId = results[0]?.taskId;
|
|
38
|
+
const sessionId = results[0]?.sessionId;
|
|
39
|
+
expect(await db.prisma.work_items.count({ where: { id: projectId, parent: { kind: "initiative", kind_data: { path: ["tree"], equals: "agent_work" } } } })).toBe(1);
|
|
40
|
+
expect(await db.prisma.work_items.count({ where: { id: taskId, parent_id: projectId, kind: "task", title: "Orchestrator" } })).toBe(1);
|
|
41
|
+
expect(await db.prisma.comments.count({ where: { work_item_id: taskId, system_key: "orchestrator-startup" } })).toBe(1);
|
|
42
|
+
expect(await db.prisma.cli_sessions.count({ where: { id: sessionId, work_item_id: taskId, role: "orchestrator", closed_at: null } })).toBe(1);
|
|
43
|
+
expect(await db.prisma.cli_session_transitions.count({ where: { session_id: sessionId, idempotency_key: `register:${projectId}` } })).toBe(1);
|
|
44
|
+
expect(await db.prisma.host_commands.count({ where: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${projectId}` } })).toBe(1);
|
|
45
|
+
} finally {
|
|
46
|
+
await db.disconnect();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -300,6 +300,8 @@ export declare const createBriefTaskHandler: import("@orpc/server").DecoratedPro
|
|
|
300
300
|
orgId: string | null | undefined;
|
|
301
301
|
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
302
302
|
work_item_id: string;
|
|
303
|
+
comment_id: string;
|
|
303
304
|
}, {
|
|
304
305
|
work_item_id: string;
|
|
306
|
+
comment_id: string;
|
|
305
307
|
}>, Record<never, never>, Record<never, never>>;
|
|
@@ -49,7 +49,7 @@ function fakePrisma(overrides: { platformOrg?: string } = {}) {
|
|
|
49
49
|
comments: {
|
|
50
50
|
create: async (a: { data: Record<string, unknown> }) => {
|
|
51
51
|
calls.push({ workItem: null, comment: a.data });
|
|
52
|
-
return a.data;
|
|
52
|
+
return { ...a.data, id: "comment-new-1" };
|
|
53
53
|
},
|
|
54
54
|
},
|
|
55
55
|
};
|
|
@@ -181,7 +181,7 @@ export async function createTaskWithBriefComment(
|
|
|
181
181
|
}).$transaction(async (tx) => {
|
|
182
182
|
const t = tx as {
|
|
183
183
|
work_items: { create: (a: unknown) => Promise<WorkItem> };
|
|
184
|
-
comments: { create: (a: unknown) => Promise<
|
|
184
|
+
comments: { create: (a: unknown) => Promise<{ id: string }> };
|
|
185
185
|
};
|
|
186
186
|
const task = await t.work_items.create({
|
|
187
187
|
data: {
|
|
@@ -203,14 +203,14 @@ export async function createTaskWithBriefComment(
|
|
|
203
203
|
},
|
|
204
204
|
});
|
|
205
205
|
// The description IS the first comment IS the brief — one write, one source.
|
|
206
|
-
await t.comments.create({
|
|
206
|
+
const firstComment = await t.comments.create({
|
|
207
207
|
data: {
|
|
208
208
|
work_item_id: task.id,
|
|
209
209
|
content: input.description,
|
|
210
210
|
user_id: userId,
|
|
211
211
|
},
|
|
212
212
|
});
|
|
213
|
-
return task;
|
|
213
|
+
return { ...task, first_comment_id: firstComment.id } as WorkItem & { first_comment_id: string };
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
|
|
@@ -471,7 +471,7 @@ export const createBriefTaskHandler = authed.handler(async ({ input, context })
|
|
|
471
471
|
},
|
|
472
472
|
userId,
|
|
473
473
|
);
|
|
474
|
-
return { work_item_id: task.id };
|
|
474
|
+
return { work_item_id: task.id, comment_id: (task as WorkItem & { first_comment_id: string }).first_comment_id };
|
|
475
475
|
} catch (err) {
|
|
476
476
|
// oRPC's default swallows a handler throw into a generic 500 with no stack;
|
|
477
477
|
// emit it so a mint failure is diagnosable, not a black box
|
|
@@ -56,8 +56,9 @@ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound"
|
|
|
56
56
|
*/
|
|
57
57
|
export declare function shapeBriefResponse(task: {
|
|
58
58
|
id: string;
|
|
59
|
-
} | null, comment: Pick<CommentRow, "content" | "created_at"> | null): {
|
|
59
|
+
} | null, comment: Pick<CommentRow, "id" | "content" | "created_at"> | null): {
|
|
60
60
|
work_item_id: string | null;
|
|
61
|
+
comment_id: string | null;
|
|
61
62
|
brief: string | null;
|
|
62
63
|
comment_created_at: string | null;
|
|
63
64
|
status: BriefReadStatus;
|
|
@@ -85,11 +86,13 @@ export declare const readAssignedBriefHandler: import("@orpc/server").DecoratedP
|
|
|
85
86
|
orgId: string | null | undefined;
|
|
86
87
|
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
87
88
|
work_item_id: string | null;
|
|
89
|
+
comment_id: string | null;
|
|
88
90
|
brief: string | null;
|
|
89
91
|
comment_created_at: string | null;
|
|
90
92
|
status: BriefReadStatus;
|
|
91
93
|
}, {
|
|
92
94
|
work_item_id: string | null;
|
|
95
|
+
comment_id: string | null;
|
|
93
96
|
brief: string | null;
|
|
94
97
|
comment_created_at: string | null;
|
|
95
98
|
status: BriefReadStatus;
|
|
@@ -16,8 +16,9 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
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, { content: "x", created_at: new Date() })).toEqual({
|
|
19
|
+
expect(shapeBriefResponse(null, { id: "c-0", content: "x", created_at: new Date() })).toEqual({
|
|
20
20
|
work_item_id: null,
|
|
21
|
+
comment_id: null,
|
|
21
22
|
brief: null,
|
|
22
23
|
comment_created_at: null,
|
|
23
24
|
status: "unbound",
|
|
@@ -30,6 +31,7 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
30
31
|
// is the broken state a fleet lane halts on rather than reading as success.
|
|
31
32
|
expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
|
|
32
33
|
work_item_id: "task-1",
|
|
34
|
+
comment_id: null,
|
|
33
35
|
brief: null,
|
|
34
36
|
comment_created_at: null,
|
|
35
37
|
status: "no_comment",
|
|
@@ -40,14 +42,16 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
40
42
|
// The comment exists but its content is empty/whitespace — "loads it
|
|
41
43
|
// empty". A fleet lane whose spawn minted a task MUST have a non-empty
|
|
42
44
|
// brief, so a blank comment is a defect the hook halts on, not a success.
|
|
43
|
-
expect(shapeBriefResponse({ id: "task-2" }, { content: " ", created_at: new Date() })).toEqual({
|
|
45
|
+
expect(shapeBriefResponse({ id: "task-2" }, { id: "c-2", content: " ", created_at: new Date() })).toEqual({
|
|
44
46
|
work_item_id: "task-2",
|
|
47
|
+
comment_id: "c-2",
|
|
45
48
|
brief: null,
|
|
46
49
|
comment_created_at: null,
|
|
47
50
|
status: "empty_brief",
|
|
48
51
|
});
|
|
49
|
-
expect(shapeBriefResponse({ id: "task-2b" }, { content: "", created_at: new Date() })).toEqual({
|
|
52
|
+
expect(shapeBriefResponse({ id: "task-2b" }, { id: "c-2b", content: "", created_at: new Date() })).toEqual({
|
|
50
53
|
work_item_id: "task-2b",
|
|
54
|
+
comment_id: "c-2b",
|
|
51
55
|
brief: null,
|
|
52
56
|
comment_created_at: null,
|
|
53
57
|
status: "empty_brief",
|
|
@@ -56,8 +60,9 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
56
60
|
|
|
57
61
|
it("returns the comment content + ISO created_at + status ready for a resolved task+comment", () => {
|
|
58
62
|
const ts = new Date("2026-08-01T03:24:00.000Z");
|
|
59
|
-
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({
|
|
60
64
|
work_item_id: "task-7",
|
|
65
|
+
comment_id: "c-7",
|
|
61
66
|
brief: "Do the thing",
|
|
62
67
|
comment_created_at: "2026-08-01T03:24:00.000Z",
|
|
63
68
|
status: "ready",
|
|
@@ -66,7 +71,7 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
|
|
|
66
71
|
|
|
67
72
|
it("serializes the comment created_at deterministically (Date → ISO)", () => {
|
|
68
73
|
const ts = new Date(0);
|
|
69
|
-
const out = shapeBriefResponse({ id: "t" }, { content: "c", created_at: ts });
|
|
74
|
+
const out = shapeBriefResponse({ id: "t" }, { id: "c", content: "c", created_at: ts });
|
|
70
75
|
expect(out.comment_created_at).toBe("1970-01-01T00:00:00.000Z");
|
|
71
76
|
});
|
|
72
77
|
});
|
|
@@ -61,25 +61,27 @@ 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, "content" | "created_at"> | null,
|
|
64
|
+
comment: Pick<CommentRow, "id" | "content" | "created_at"> | null,
|
|
65
65
|
): {
|
|
66
66
|
work_item_id: string | null;
|
|
67
|
+
comment_id: string | null;
|
|
67
68
|
brief: string | null;
|
|
68
69
|
comment_created_at: string | null;
|
|
69
70
|
status: BriefReadStatus;
|
|
70
71
|
} {
|
|
71
72
|
if (!task) {
|
|
72
|
-
return { work_item_id: null, brief: null, comment_created_at: null, status: "unbound" };
|
|
73
|
+
return { work_item_id: null, comment_id: null, brief: null, comment_created_at: null, status: "unbound" };
|
|
73
74
|
}
|
|
74
75
|
if (!comment) {
|
|
75
|
-
return { work_item_id: task.id, brief: null, comment_created_at: null, status: "no_comment" };
|
|
76
|
+
return { work_item_id: task.id, comment_id: null, brief: null, comment_created_at: null, status: "no_comment" };
|
|
76
77
|
}
|
|
77
78
|
const text = typeof comment.content === "string" ? comment.content : String(comment.content);
|
|
78
79
|
if (text.trim() === "") {
|
|
79
|
-
return { work_item_id: task.id, brief: null, comment_created_at: null, status: "empty_brief" };
|
|
80
|
+
return { work_item_id: task.id, comment_id: comment.id, brief: null, comment_created_at: null, status: "empty_brief" };
|
|
80
81
|
}
|
|
81
82
|
return {
|
|
82
83
|
work_item_id: task.id,
|
|
84
|
+
comment_id: comment.id,
|
|
83
85
|
brief: text,
|
|
84
86
|
comment_created_at:
|
|
85
87
|
comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
|
|
@@ -324,11 +324,13 @@ export declare const taskSyncRouter: {
|
|
|
324
324
|
orgId: string | null | undefined;
|
|
325
325
|
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
326
326
|
work_item_id: string | null;
|
|
327
|
+
comment_id: string | null;
|
|
327
328
|
brief: string | null;
|
|
328
329
|
comment_created_at: string | null;
|
|
329
330
|
status: import("./task-sync-brief.ts").BriefReadStatus;
|
|
330
331
|
}, {
|
|
331
332
|
work_item_id: string | null;
|
|
333
|
+
comment_id: string | null;
|
|
332
334
|
brief: string | null;
|
|
333
335
|
comment_created_at: string | null;
|
|
334
336
|
status: import("./task-sync-brief.ts").BriefReadStatus;
|
|
@@ -342,8 +344,10 @@ export declare const taskSyncRouter: {
|
|
|
342
344
|
orgId: string | null | undefined;
|
|
343
345
|
}>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
|
|
344
346
|
work_item_id: string;
|
|
347
|
+
comment_id: string;
|
|
345
348
|
}, {
|
|
346
349
|
work_item_id: string;
|
|
350
|
+
comment_id: string;
|
|
347
351
|
}>, Record<never, never>, Record<never, never>>;
|
|
348
352
|
/** RUNTIME→DB lifecycle close: mark lane.live=false and reconcile task status before the ACP owner releases the session. */
|
|
349
353
|
closeLane: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
|
|
@@ -25,6 +25,13 @@
|
|
|
25
25
|
* WebSocket upgrades are returned untouched (Bun tracks upgrades by Request
|
|
26
26
|
* identity — a clone can never upgrade).
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* Preserve useful process attribution without copying arbitrary argv into logs.
|
|
30
|
+
* Arguments routinely contain bearer headers, inline scripts, URLs and secret
|
|
31
|
+
* values. There is no complete flag-name redaction list, so the safe boundary
|
|
32
|
+
* is structural: retain argv[0] and state that arguments were removed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function summariseLocalCommand(cmdline: readonly string[]): string | null;
|
|
28
35
|
/**
|
|
29
36
|
* Return a Request with transport-identity headers stamped, or the original
|
|
30
37
|
* `req` when no stamping is needed/possible. Never throws.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { summariseLocalCommand } from "./stamp-transport-identity.ts";
|
|
3
|
+
|
|
4
|
+
describe("local command attribution", () => {
|
|
5
|
+
test("never copies command arguments into telemetry", () => {
|
|
6
|
+
const token = "operator-token-must-not-appear";
|
|
7
|
+
const summary = summariseLocalCommand([
|
|
8
|
+
"curl",
|
|
9
|
+
"-H",
|
|
10
|
+
`x-operator-spawn-token: ${token}`,
|
|
11
|
+
"http://127.0.0.1:3021/rpc/spawn",
|
|
12
|
+
]);
|
|
13
|
+
expect(summary).toBe("curl [arguments redacted]");
|
|
14
|
+
expect(summary).not.toContain(token);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("keeps a lone executable name", () => {
|
|
18
|
+
expect(summariseLocalCommand(["scala-picker"])).toBe("scala-picker");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("omits an empty command line", () => {
|
|
22
|
+
expect(summariseLocalCommand([])).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -31,6 +31,18 @@ import { resolveSocketPeer } from "@teamscala/proc-walker/socket-peer";
|
|
|
31
31
|
|
|
32
32
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Preserve useful process attribution without copying arbitrary argv into logs.
|
|
36
|
+
* Arguments routinely contain bearer headers, inline scripts, URLs and secret
|
|
37
|
+
* values. There is no complete flag-name redaction list, so the safe boundary
|
|
38
|
+
* is structural: retain argv[0] and state that arguments were removed.
|
|
39
|
+
*/
|
|
40
|
+
export function summariseLocalCommand(cmdline: readonly string[]): string | null {
|
|
41
|
+
const executable = cmdline[0]?.trim();
|
|
42
|
+
if (!executable) return null;
|
|
43
|
+
return cmdline.length > 1 ? `${executable} [arguments redacted]` : executable;
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/**
|
|
35
47
|
* Return a Request with transport-identity headers stamped, or the original
|
|
36
48
|
* `req` when no stamping is needed/possible. Never throws.
|
|
@@ -77,8 +89,8 @@ export function stampTransportIdentity(
|
|
|
77
89
|
const owner = resolveSocketPeer(port);
|
|
78
90
|
if (owner) {
|
|
79
91
|
headers.set(CALLER_HEADERS.LOCAL_COMM, owner.comm);
|
|
80
|
-
const cmd = owner.cmdline
|
|
81
|
-
if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd
|
|
92
|
+
const cmd = summariseLocalCommand(owner.cmdline);
|
|
93
|
+
if (cmd) headers.set(CALLER_HEADERS.LOCAL_CMD, cmd);
|
|
82
94
|
}
|
|
83
95
|
}
|
|
84
96
|
return new Request(req.url, {
|