@teamscala/service-boot 0.23.322 → 0.23.324
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +526 -526
- package/src/agreement-gate.ts +12 -0
- package/src/auth-gate.test.ts +70 -0
- package/src/auth-gate.ts +68 -6
- package/src/build-client-assets.test.ts +1 -1
- package/src/build-client-assets.ts +1 -1
- package/src/discover-blocks.ts +1 -1
- package/src/fn-routers/index.ts +2 -0
- package/src/fn-routers/task-sync-assign.test.ts +2 -1
- 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.test.ts +20 -17
- package/src/fn-routers/task-sync-brief.ts +15 -14
- package/src/fn-routers/task-sync-close.test.ts +44 -0
- package/src/fn-routers/task-sync-close.ts +82 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
- package/src/fn-routers/task-sync-lane.test.ts +36 -0
- package/src/fn-routers/task-sync-projection.test.ts +26 -0
- package/src/fn-routers/task-sync-projection.ts +51 -0
- package/src/fn-routers/task-sync-rename.test.ts +53 -0
- package/src/fn-routers/task-sync-rename.ts +111 -0
- package/src/fn-routers/task-sync-router.test.ts +14 -2
- package/src/handlers/singleton-tools-currency-watch.ts +114 -11
- package/src/handlers/sweep-host-cache.test.ts +133 -0
- package/src/handlers/sweep-host-cache.ts +143 -26
- package/src/install-logging.ts +3 -0
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-page-routes.ts +0 -3
- package/src/resolve-brand-favicon.ts +1 -1
- package/src/resource-not-found.ts +10 -0
- package/src/serve/stamp-transport-identity.test.ts +24 -0
- package/src/serve/stamp-transport-identity.ts +14 -2
- package/src/two-phase-boot.ts +85 -17
- package/src/two-phase-health-gate-client-only.test.ts +77 -0
- package/src/two-phase-health-gate.test.ts +13 -2
- package/src/agreement-gate.d.ts +0 -19
- package/src/asset-cache-key.d.ts +0 -11
- package/src/asset-optimize.d.ts +0 -17
- package/src/asset-proxy.d.ts +0 -11
- package/src/asset-social-card.d.ts +0 -17
- package/src/attach-agreement-magic.d.ts +0 -20
- package/src/attach-portal-members.d.ts +0 -23
- package/src/attach-proposal-accept.d.ts +0 -26
- package/src/attach-proposal-magic.d.ts +0 -18
- package/src/attach-transfers-upload.d.ts +0 -27
- package/src/auth-gate.d.ts +0 -28
- package/src/bind-page-handler.d.ts +0 -18
- package/src/build-client-assets/input-hashing.d.ts +0 -25
- package/src/build-client-assets/source-candidates.d.ts +0 -27
- package/src/build-client-assets.d.ts +0 -50
- package/src/compose-page-title.d.ts +0 -41
- package/src/content-gate/access.d.ts +0 -15
- package/src/content-gate/config-types.d.ts +0 -99
- package/src/content-gate/gate.d.ts +0 -13
- package/src/content-gate/parse-cookie.d.ts +0 -9
- package/src/content-gate/token.d.ts +0 -48
- package/src/content-page-floor.d.ts +0 -39
- package/src/discover-blocks.d.ts +0 -28
- package/src/expand-roster.d.ts +0 -34
- package/src/fn-routers/agreement-completion-email.d.ts +0 -14
- package/src/fn-routers/agreement-send-router.d.ts +0 -82
- package/src/fn-routers/ai-health-router.d.ts +0 -35
- package/src/fn-routers/auth-router.d.ts +0 -47
- package/src/fn-routers/cache-admin-router.d.ts +0 -57
- package/src/fn-routers/chat-session-router.d.ts +0 -219
- package/src/fn-routers/email-defaults.d.ts +0 -1
- package/src/fn-routers/email-link-ttl.d.ts +0 -1
- package/src/fn-routers/index.d.ts +0 -18
- package/src/fn-routers/panel-data-router.d.ts +0 -19
- package/src/fn-routers/persist-chat-messages.d.ts +0 -36
- package/src/fn-routers/plans-router.d.ts +0 -25
- package/src/fn-routers/proposal-send-router.d.ts +0 -58
- package/src/fn-routers/public-agreement-router.d.ts +0 -289
- package/src/fn-routers/public-assessment-router.d.ts +0 -68
- package/src/fn-routers/public-gpt-session-router.d.ts +0 -143
- package/src/fn-routers/sign-in-flow-router.d.ts +0 -107
- package/src/fn-routers/task-sync-assign.d.ts +0 -305
- package/src/fn-routers/task-sync-brief.d.ts +0 -96
- package/src/fn-routers/task-sync-dispatch.d.ts +0 -61
- package/src/fn-routers/task-sync-dispatch.ts +0 -124
- package/src/fn-routers/task-sync-lane.d.ts +0 -49
- package/src/fn-routers/task-sync-lane.ts +0 -125
- package/src/fn-routers/task-sync-projection.d.ts +0 -121
- package/src/fn-routers/task-sync-router.d.ts +0 -402
- package/src/fn-routers/task-sync-router.ts +0 -1097
- package/src/fn-routers/tasks-router.d.ts +0 -25
- package/src/fn-routers/user-preferences-router.d.ts +0 -77
- package/src/fn-routers/work-items-router.d.ts +0 -102
- package/src/gate-ttls.d.ts +0 -6
- package/src/gpt-gate.d.ts +0 -25
- package/src/handlers/bun-stable-watch.d.ts +0 -1
- package/src/handlers/pane-outbox-drain.d.ts +0 -1
- package/src/handlers/projects-digest.d.ts +0 -26
- package/src/handlers/prune-stale-data.d.ts +0 -30
- package/src/handlers/registry-stale-inactive-sweep.d.ts +0 -1
- package/src/handlers/scala-tools-currency-watch.d.ts +0 -1
- package/src/handlers/singleton-tools-currency-watch.d.ts +0 -1
- package/src/handlers/sweep-event-log.d.ts +0 -1
- package/src/handlers/sweep-host-cache.d.ts +0 -1
- package/src/handlers/upstream-issue-sync.d.ts +0 -1
- package/src/handlers/whatsapp-liveness-logic.d.ts +0 -34
- package/src/handlers/whatsapp-liveness.d.ts +0 -4
- package/src/hono-error-handler.d.ts +0 -25
- package/src/install-harness.d.ts +0 -45
- package/src/install-logging.d.ts +0 -34
- package/src/load-service-install-row.d.ts +0 -24
- package/src/load-service-prisma-client.d.ts +0 -18
- package/src/mount-orpc-router.d.ts +0 -15
- package/src/mount-server-packages.d.ts +0 -29
- package/src/mount.d.ts +0 -9
- package/src/mutable-app-ref.d.ts +0 -44
- package/src/page-route-shell-types.d.ts +0 -12
- package/src/page-server-orpc/audit-revert-router.d.ts +0 -37
- package/src/page-server-orpc/hub-mcp-client.d.ts +0 -12
- package/src/page-server-orpc/portal-diagnostics-router.d.ts +0 -49
- package/src/page-server-orpc/puck-pages-router.d.ts +0 -58
- package/src/page-server-orpc/server-client.d.ts +0 -16
- package/src/page-server-orpc/templates-router.d.ts +0 -34
- package/src/page-server-orpc/user-preferences-router.d.ts +0 -79
- package/src/pg-boss-handler-registry.d.ts +0 -27
- package/src/pg-boss-mount.d.ts +0 -42
- package/src/proposal-authorized-emails.d.ts +0 -28
- package/src/proposal-chat-inject.d.ts +0 -17
- package/src/proposal-gate.d.ts +0 -18
- package/src/read-html-template.d.ts +0 -38
- package/src/read-viewport-from-request.d.ts +0 -18
- package/src/readiness-gate.d.ts +0 -16
- package/src/register-api-routes.d.ts +0 -18
- package/src/register-crash-handlers.d.ts +0 -15
- package/src/register-page-routes.d.ts +0 -27
- package/src/register-page-server-client.d.ts +0 -11
- package/src/register-registry-page-route/ai-chat-prefetch.d.ts +0 -13
- package/src/register-registry-page-route/graph-cache.d.ts +0 -30
- package/src/register-registry-page-route/meta-title.d.ts +0 -2
- package/src/register-registry-page-route/proposal-approval-email-token.d.ts +0 -14
- package/src/register-registry-page-route/proposal-brand-stamping.d.ts +0 -28
- package/src/register-registry-page-route/resolve-page-data.d.ts +0 -1
- package/src/register-registry-page-route.d.ts +0 -27
- package/src/register-static-assets.d.ts +0 -13
- package/src/register-ws-routes.d.ts +0 -28
- package/src/resolve-app-base-url.d.ts +0 -13
- package/src/resolve-brand-favicon.d.ts +0 -28
- package/src/resolve-from-loader-config.d.ts +0 -32
- package/src/resolve-service-context.d.ts +0 -17
- package/src/resolve-service-port.d.ts +0 -75
- package/src/resolve-theme-mode.d.ts +0 -42
- package/src/resolvers/fetch-org-brand.d.ts +0 -2
- package/src/resolvers/portal-route-paths.d.ts +0 -39
- package/src/resolvers/website-by-host-map.d.ts +0 -12
- package/src/resource-chat-inject.d.ts +0 -46
- package/src/resource-not-found.d.ts +0 -60
- package/src/run-boot-hook-list.d.ts +0 -19
- package/src/run-boot-hooks.d.ts +0 -31
- package/src/scala-tools-currency-watchdog.d.ts +0 -10
- package/src/security-headers.d.ts +0 -12
- package/src/serve/serve.d.ts +0 -46
- package/src/serve/stamp-transport-identity.d.ts +0 -32
- package/src/server-error-page.d.ts +0 -29
- package/src/start-scheduled-jobs.d.ts +0 -18
- package/src/two-phase-boot.d.ts +0 -136
- package/src/types.d.ts +0 -46
- package/src/ui-service.d.ts +0 -46
- package/src/ws-boot-self-test.d.ts +0 -19
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Lane-close write-through for task-sync. The session-owning fleet runtime
|
|
7
|
+
* calls this ORPC procedure before releasing an ACP lane, so work_items remains
|
|
8
|
+
* the durable lifecycle source without a second raw-SQL writer in the host.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ORPCError } from "@orpc/server";
|
|
12
|
+
import * as v from "valibot";
|
|
13
|
+
import { authed, type Context } from "./auth-router.ts";
|
|
14
|
+
import type { Prisma, WorkItem } from "./task-sync-router.ts";
|
|
15
|
+
|
|
16
|
+
export const closeLaneInput = v.object({
|
|
17
|
+
lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
|
|
18
|
+
disposition: v.picklist([
|
|
19
|
+
"completed",
|
|
20
|
+
"needs_retry",
|
|
21
|
+
"failed",
|
|
22
|
+
"orchestrator_closed",
|
|
23
|
+
"orphaned",
|
|
24
|
+
]),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export interface ClosedTaskState {
|
|
28
|
+
task_id: string;
|
|
29
|
+
task_status: "done" | "archived";
|
|
30
|
+
completed: boolean;
|
|
31
|
+
kind_data: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function closedTaskState(
|
|
35
|
+
task: Pick<WorkItem, "id" | "kind_data">,
|
|
36
|
+
disposition: v.InferOutput<typeof closeLaneInput>["disposition"],
|
|
37
|
+
): ClosedTaskState {
|
|
38
|
+
const completed = disposition === "completed";
|
|
39
|
+
const lane =
|
|
40
|
+
task.kind_data.lane && typeof task.kind_data.lane === "object"
|
|
41
|
+
? (task.kind_data.lane as Record<string, unknown>)
|
|
42
|
+
: {};
|
|
43
|
+
return {
|
|
44
|
+
task_id: task.id,
|
|
45
|
+
task_status: completed ? "done" : "archived",
|
|
46
|
+
completed,
|
|
47
|
+
kind_data: { ...task.kind_data, lane: { ...lane, live: false } },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const closeLaneHandler = authed
|
|
52
|
+
.input(closeLaneInput)
|
|
53
|
+
.handler(async ({ input, context }) => {
|
|
54
|
+
const prisma = (context as Context).prisma as Prisma;
|
|
55
|
+
const task = await prisma.work_items.findFirst({
|
|
56
|
+
where: {
|
|
57
|
+
kind: "task",
|
|
58
|
+
kind_data: { path: ["lane", "session"], equals: input.lane_session },
|
|
59
|
+
},
|
|
60
|
+
orderBy: { created_at: "desc" },
|
|
61
|
+
});
|
|
62
|
+
if (!task) {
|
|
63
|
+
throw new ORPCError("NOT_FOUND", {
|
|
64
|
+
message: `No task is bound to ACP lane ${input.lane_session}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const state = closedTaskState(task, input.disposition);
|
|
68
|
+
await prisma.work_items.update({
|
|
69
|
+
where: { id: task.id },
|
|
70
|
+
data: {
|
|
71
|
+
kind_data: state.kind_data,
|
|
72
|
+
status: state.task_status,
|
|
73
|
+
completed: state.completed,
|
|
74
|
+
},
|
|
75
|
+
select: { id: true },
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
task_id: state.task_id,
|
|
79
|
+
task_status: state.task_status,
|
|
80
|
+
reconciled: true,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
@@ -3,42 +3,60 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
* Unit tests for the pure
|
|
7
|
-
*
|
|
8
|
-
* DB->AGENT direction demo — a published orchestrator.message with slotName
|
|
9
|
-
* cross-pane delivers to the target pane's <channel> tag); buildTaskChangedEvent
|
|
10
|
-
* is pure and tested here. Satisfies what-must-have-a-test / primitive-api-has-test.
|
|
6
|
+
* Unit tests for the pure durable comment-payload builder in task-sync-dispatch.
|
|
7
|
+
* The runtime path writes this payload through the canonical comments model.
|
|
11
8
|
*/
|
|
12
9
|
import { describe, it, expect } from "bun:test";
|
|
13
|
-
import {
|
|
10
|
+
import { buildTaskChangeComment, notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
|
|
14
11
|
|
|
15
|
-
describe("task-sync
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
12
|
+
describe("task-sync buildTaskChangeComment", () => {
|
|
13
|
+
it("addresses the canonical task and runtime with no pane transport", () => {
|
|
14
|
+
const comment = buildTaskChangeComment("task-7", "session-5", "subtask-1:completed");
|
|
15
|
+
expect(comment).toMatchObject({
|
|
16
|
+
work_item_id: "task-7",
|
|
17
|
+
source_cli_session_id: "session-5",
|
|
18
|
+
sender_role: "system",
|
|
19
|
+
sender_name: "TaskSync",
|
|
20
|
+
});
|
|
21
|
+
expect(comment.system_key).toBe("task-sync:task-7:session-5:subtask-1:completed");
|
|
22
|
+
expect(comment.content).toContain("readTaskList");
|
|
23
|
+
expect(comment).not.toHaveProperty("pane");
|
|
24
|
+
});
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
it("writes one durable comment addressed by the active cli_session", async () => {
|
|
27
|
+
const calls: unknown[] = [];
|
|
28
|
+
const result = await notifyLaneOfTaskChange(
|
|
29
|
+
{
|
|
30
|
+
cli_sessions: { findFirst: async () => ({ id: "session-5" }) },
|
|
31
|
+
comments: {
|
|
32
|
+
upsert: async (args) => {
|
|
33
|
+
calls.push(args);
|
|
34
|
+
return { id: "comment-1" };
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
"task-7",
|
|
39
|
+
"subtask-1:completed",
|
|
40
|
+
);
|
|
41
|
+
expect(result).toMatchObject({ delivered: true, sessionId: "session-5", commentId: "comment-1" });
|
|
42
|
+
expect(calls[0]).toMatchObject({ create: { work_item_id: "task-7", source_cli_session_id: "session-5" } });
|
|
43
|
+
});
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
it("does not fabricate a pre-launch session or comment", async () => {
|
|
46
|
+
let writes = 0;
|
|
47
|
+
const result = await notifyLaneOfTaskChange(
|
|
48
|
+
{
|
|
49
|
+
cli_sessions: { findFirst: async () => null },
|
|
50
|
+
comments: {
|
|
51
|
+
upsert: async () => {
|
|
52
|
+
writes++;
|
|
53
|
+
return { id: "unexpected" };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"task-7",
|
|
58
|
+
);
|
|
59
|
+
expect(result).toEqual({ delivered: false, sessionId: null, commentId: null });
|
|
60
|
+
expect(writes).toBe(0);
|
|
61
|
+
});
|
|
43
62
|
});
|
|
44
|
-
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Behavioral tests for canonical CLI-session lane resolution.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import { resolveLaneWorkName } from "./task-sync-lane.ts";
|
|
10
|
+
|
|
11
|
+
describe("resolveLaneWorkName", () => {
|
|
12
|
+
it("reads the visible label from the active cli_sessions route", async () => {
|
|
13
|
+
let query: unknown;
|
|
14
|
+
const name = await resolveLaneWorkName(
|
|
15
|
+
{
|
|
16
|
+
cli_sessions: {
|
|
17
|
+
findFirst: async (args: unknown) => {
|
|
18
|
+
query = args;
|
|
19
|
+
return { id: "session-1", label: "ZeroStack" };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
} as never,
|
|
23
|
+
"tmux:%7",
|
|
24
|
+
);
|
|
25
|
+
expect(name).toBe("ZeroStack");
|
|
26
|
+
expect(query).toMatchObject({ where: { host_route: { path: ["pane_id"], equals: "tmux:%7" } } });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns no unverified title when no active session owns the pane", async () => {
|
|
30
|
+
const name = await resolveLaneWorkName(
|
|
31
|
+
{ cli_sessions: { findFirst: async () => null } } as never,
|
|
32
|
+
"tmux:%8",
|
|
33
|
+
);
|
|
34
|
+
expect(name).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { describe, expect, it } from "bun:test";
|
|
13
13
|
import {
|
|
14
14
|
STATUS_TO_WORK_ITEM,
|
|
15
|
+
deriveEvidenceProgress,
|
|
15
16
|
normalizeStatus,
|
|
16
17
|
planReconcile,
|
|
17
18
|
resolveProjectedStatus,
|
|
@@ -29,6 +30,31 @@ describe("STATUS_TO_WORK_ITEM", () => {
|
|
|
29
30
|
});
|
|
30
31
|
});
|
|
31
32
|
|
|
33
|
+
describe("evidence-derived task progress", () => {
|
|
34
|
+
it("counts only required subtasks with independent Orchestrator acceptance", () => {
|
|
35
|
+
const out = deriveEvidenceProgress(
|
|
36
|
+
[
|
|
37
|
+
{ id: "a", status: "done" },
|
|
38
|
+
{ id: "b", status: "done" },
|
|
39
|
+
{ id: "c", status: "todo", kind_data: { pm_progress: { required: false } } },
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
{ work_item_id: "a", sender_role: "orchestrator", system_key: "acceptance:a" },
|
|
43
|
+
{ work_item_id: "b", sender_role: "worker", system_key: "acceptance:b" },
|
|
44
|
+
],
|
|
45
|
+
);
|
|
46
|
+
expect(out).toEqual({ total: 2, accepted: 1, percentage: 50 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not treat prose, context, or done alone as evidence", () => {
|
|
50
|
+
expect(
|
|
51
|
+
deriveEvidenceProgress([{ id: "a", status: "done" }], [
|
|
52
|
+
{ work_item_id: "a", sender_role: "orchestrator", system_key: "progress:100" },
|
|
53
|
+
]),
|
|
54
|
+
).toEqual({ total: 1, accepted: 0, percentage: 0 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
32
58
|
describe("status translation is a single non-drifting source", () => {
|
|
33
59
|
// The forward map (CLI → work_items) is canonical; the reverse is DERIVED from
|
|
34
60
|
// it (+ a closed synonym set). This locks the structural fix: the two
|
|
@@ -74,6 +74,57 @@ export function normalizeStatus(s: string | null): TaskListItem["status"] {
|
|
|
74
74
|
return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export interface ProgressSubtask {
|
|
78
|
+
id: string;
|
|
79
|
+
status: string | null;
|
|
80
|
+
kind_data?: Record<string, unknown> | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ProgressAcceptanceEvidence {
|
|
84
|
+
work_item_id: string | null;
|
|
85
|
+
sender_role: string | null;
|
|
86
|
+
system_key: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface EvidenceProgress {
|
|
90
|
+
total: number;
|
|
91
|
+
accepted: number;
|
|
92
|
+
percentage: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Derive progress from the canonical checklist and independent acceptance
|
|
97
|
+
* comments. A done status without an Orchestrator acceptance comment is not
|
|
98
|
+
* evidence of completion; prose percentages and context gauges never enter
|
|
99
|
+
* this calculation. Subtasks are required by default, with an explicit
|
|
100
|
+
* `kind_data.pm_progress.required: false` opt-out for non-gating notes.
|
|
101
|
+
*/
|
|
102
|
+
export function deriveEvidenceProgress(
|
|
103
|
+
subtasks: ProgressSubtask[],
|
|
104
|
+
evidence: ProgressAcceptanceEvidence[],
|
|
105
|
+
): EvidenceProgress {
|
|
106
|
+
const required = subtasks.filter(
|
|
107
|
+
(s) => (s.kind_data?.pm_progress as { required?: boolean } | undefined)?.required !== false,
|
|
108
|
+
);
|
|
109
|
+
const accepted = new Set(
|
|
110
|
+
evidence
|
|
111
|
+
.filter(
|
|
112
|
+
(e) =>
|
|
113
|
+
e.sender_role === "orchestrator" &&
|
|
114
|
+
typeof e.system_key === "string" &&
|
|
115
|
+
e.system_key.startsWith("acceptance:") &&
|
|
116
|
+
e.work_item_id,
|
|
117
|
+
)
|
|
118
|
+
.map((e) => e.work_item_id as string),
|
|
119
|
+
);
|
|
120
|
+
const acceptedCount = required.filter((s) => s.status === "done" && accepted.has(s.id)).length;
|
|
121
|
+
return {
|
|
122
|
+
total: required.length,
|
|
123
|
+
accepted: acceptedCount,
|
|
124
|
+
percentage: required.length === 0 ? 0 : Math.round((acceptedCount / required.length) * 100),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
77
128
|
/**
|
|
78
129
|
* Decide the status to WRITE for an existing row, given what the CLI list says.
|
|
79
130
|
*
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit directly
|
|
5
|
+
*
|
|
6
|
+
* Unit tests for the rename back-write leg: renameOutcome (the pure
|
|
7
|
+
* no-task ⇒ not-updated decision) + source-contract assertions that the handler
|
|
8
|
+
* resolves the lane task BY SESSION (never pane) and updates its title. Covers
|
|
9
|
+
* `what-must-have-a-test` for the new procedure + the `lane-identity-is-cli-
|
|
10
|
+
* session-id` invariant it rides on.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "bun:test";
|
|
13
|
+
import { renameOutcome } from "./task-sync-rename.ts";
|
|
14
|
+
|
|
15
|
+
describe("renameOutcome (no verdict from no data)", () => {
|
|
16
|
+
it("returns not-updated with a null task_id when no task is bound to the session", () => {
|
|
17
|
+
// A rename of a tab whose lane has no resolvable task updates nothing —
|
|
18
|
+
// never an error, never a fake id. updated:false is the signal the caller
|
|
19
|
+
// treats as best-effort (the window was already renamed; this only missed
|
|
20
|
+
// the DB echo, which is the rare archived-and-pruned case).
|
|
21
|
+
expect(renameOutcome(null)).toEqual({ task_id: null, updated: false });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns the bound task id + updated:true when a task resolves", () => {
|
|
25
|
+
expect(renameOutcome({ id: "task-9" })).toEqual({ task_id: "task-9", updated: true });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("renameLaneTask resolves BY SESSION and writes the title (source contract)", () => {
|
|
30
|
+
const renameSrc = Bun.file(new URL("./task-sync-rename.ts", import.meta.url).pathname);
|
|
31
|
+
|
|
32
|
+
it("resolves the lane task by lane_session (the CLI session id), never by pane", async () => {
|
|
33
|
+
// `lane-identity-is-cli-session-id`: binding is by session because a pane
|
|
34
|
+
// outlives the session inside it. A pane-keyed rename would let a dead
|
|
35
|
+
// session's task answer for a live lane.
|
|
36
|
+
const text = await renameSrc.text();
|
|
37
|
+
expect(text).toContain("resolveLaneTask(prisma, lane_session)");
|
|
38
|
+
// No pane matching anywhere in the resolution path.
|
|
39
|
+
expect(text).not.toContain("lane.pane");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("updates the resolved task's title by id", async () => {
|
|
43
|
+
const text = await renameSrc.text();
|
|
44
|
+
expect(text).toContain('where: { id: task.id }');
|
|
45
|
+
expect(text).toContain("data: { title }");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("requires lane_session + title at the signature (incomplete rename unconstructible)", async () => {
|
|
49
|
+
const text = await renameSrc.text();
|
|
50
|
+
expect(text).toContain('lane_session: v.pipe(v.string()');
|
|
51
|
+
expect(text).toContain('title: v.pipe(v.string()');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Lane RENAME back-write for task-sync: a tab rename EMITS to update the bound
|
|
7
|
+
* work_items task's title in the DB, by CLI session id. This is the DB half of
|
|
8
|
+
* `tab-rename-emits-back-to-task-title`: the operator's tab bar is how they see
|
|
9
|
+
* what is being worked on (`lane-labels-and-task-lists`), so the tmux window
|
|
10
|
+
* name and the task title must never diverge silently. Renaming a tab from
|
|
11
|
+
* `scala-tools session rename` (or the lane renaming itself through the same
|
|
12
|
+
* surface) updates the task title so the project board agrees with the tab bar.
|
|
13
|
+
*
|
|
14
|
+
* THE EMISSION, not a poll (`actions-are-rows-then-emissions`): a caller renames
|
|
15
|
+
* the window (a row/state change) and that change emits the title update — the
|
|
16
|
+
* task-sync owner reacts by updating the row. State lives in the row, never in a
|
|
17
|
+
* return value a caller may never receive.
|
|
18
|
+
*
|
|
19
|
+
* Bound by CLI SESSION ID, never pane id (`lane-identity-is-cli-session-id`): a
|
|
20
|
+
* pane OUTLIVES the session inside it, so matching on a pane would let a dead
|
|
21
|
+
* session's task answer for a live lane. The session is the stable identity a
|
|
22
|
+
* rename resolves to (the daemon resolves the pane's tab → its stored session id
|
|
23
|
+
* and passes that here), so the rename lands on the lane that owns the tab.
|
|
24
|
+
*
|
|
25
|
+
* Split out of task-sync-router.ts for the same reason task-sync-brief/lane/
|
|
26
|
+
* assign exist: the router sits at its 1000-line commit gate, so every cohesive
|
|
27
|
+
* task-sync unit that can live in a sibling does. This owns the rename handler +
|
|
28
|
+
* its pure outcome decision.
|
|
29
|
+
*
|
|
30
|
+
* CAPABILITY HARVEST (capability-harvest-on-split): APP-LOCAL, not a capability
|
|
31
|
+
* candidate. Bound to the work_items schema and to task-sync's own
|
|
32
|
+
* kind_data.lane.session convention; no unrelated app could install this
|
|
33
|
+
* unmodified.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import * as v from "valibot";
|
|
37
|
+
import type { Prisma } from "./task-sync-router.ts";
|
|
38
|
+
import { authed, type Context } from "./auth-router.ts";
|
|
39
|
+
import { resolveLaneTask } from "./task-sync-lane.ts";
|
|
40
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* renameLaneTask ORPC input. `lane_session` is the lane's CLI-native session id
|
|
44
|
+
* (kind_data.lane.session) — the stable identity a pane rename resolves to.
|
|
45
|
+
* `title` is the new task title (the renamed tab's label). Both required:
|
|
46
|
+
* oRPC rejects a call missing either at the signature
|
|
47
|
+
* (`prevention-over-detection`), so a malformed rename is a 400, never a silent
|
|
48
|
+
* no-op that leaves the title stale.
|
|
49
|
+
*/
|
|
50
|
+
export const renameLaneTaskInput = v.object({
|
|
51
|
+
lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
|
|
52
|
+
title: v.pipe(v.string(), v.minLength(1, "title is required")),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The rename outcome from a resolved lane task — pure so the "no task bound ⇒
|
|
57
|
+
* not updated, never an error" decision is unit-testable with no DB. A rename
|
|
58
|
+
* whose lane has no task is best-effort (returns updated:false): every spawn
|
|
59
|
+
* now mints a task, so this is the rare case (a tab whose task was archived and
|
|
60
|
+
* pruned), not a path the operator hits. Failing loudly here would refuse a
|
|
61
|
+
* rename of the WINDOW for a DB miss, which couples two surfaces that must not
|
|
62
|
+
* be — the tmux rename already succeeded before this is called.
|
|
63
|
+
*/
|
|
64
|
+
export function renameOutcome(task: { id: string } | null): {
|
|
65
|
+
task_id: string | null;
|
|
66
|
+
updated: boolean;
|
|
67
|
+
} {
|
|
68
|
+
if (!task) return { task_id: null, updated: false };
|
|
69
|
+
return { task_id: task.id, updated: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* renameLaneTask ORPC handler. The session-picker daemon calls this from
|
|
74
|
+
* `/rpc/rename` after it renames the tmux window: it resolves the pane's tab to
|
|
75
|
+
* its session id and posts it here. Same transport as bindLane/readTaskList:
|
|
76
|
+
* page-gate-exempt (fn.taskSync), localhost super-admin. Resolves the lane's
|
|
77
|
+
* task BY SESSION (never pane) and updates its title. Idempotent: renaming to
|
|
78
|
+
* the same title writes the same row.
|
|
79
|
+
*
|
|
80
|
+
* An archived parent is matched too (resolveLaneTask falls back to the archived
|
|
81
|
+
* lane task for the session), so a rename of a tab whose run closed still lands
|
|
82
|
+
* on its task — the title is the title regardless of run state.
|
|
83
|
+
*/
|
|
84
|
+
export const renameLaneTaskHandler = authed
|
|
85
|
+
.input(renameLaneTaskInput)
|
|
86
|
+
.handler(async ({ input, context }) => {
|
|
87
|
+
const ctx = context as Context;
|
|
88
|
+
const prisma = ctx.prisma as Prisma;
|
|
89
|
+
const { lane_session, title } = input;
|
|
90
|
+
const task = await resolveLaneTask(prisma, lane_session);
|
|
91
|
+
const outcome = renameOutcome(task);
|
|
92
|
+
if (!task) return outcome;
|
|
93
|
+
try {
|
|
94
|
+
await prisma.work_items.update({
|
|
95
|
+
where: { id: task.id },
|
|
96
|
+
data: { title },
|
|
97
|
+
select: { id: true },
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// The window was already renamed by the caller; a DB update failure must
|
|
101
|
+
// not surface as an error that implies the rename failed. Report
|
|
102
|
+
// not-updated + log, so the two surfaces stay decoupled
|
|
103
|
+
// (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
104
|
+
getAppLogger().warn("[task-sync] renameLaneTask: title update failed", {
|
|
105
|
+
taskId: task.id,
|
|
106
|
+
error: err instanceof Error ? err.message : String(err),
|
|
107
|
+
});
|
|
108
|
+
return { task_id: task.id, updated: false };
|
|
109
|
+
}
|
|
110
|
+
return outcome;
|
|
111
|
+
});
|
|
@@ -337,8 +337,20 @@ describe("a blocked row survives a CLI list that cannot express it", () => {
|
|
|
337
337
|
it("planReconcile leaves a blocked subtask blocked when the list says pending", () => {
|
|
338
338
|
// The end-to-end shape: this is what re-blocked the lane three times.
|
|
339
339
|
const plan = planReconcile(
|
|
340
|
-
[
|
|
341
|
-
|
|
340
|
+
[
|
|
341
|
+
{
|
|
342
|
+
id: "row-1",
|
|
343
|
+
kind: "subtask",
|
|
344
|
+
parent_id: "task-1",
|
|
345
|
+
organisation_id: "org-1",
|
|
346
|
+
title: "Deploy the fix",
|
|
347
|
+
status: "blocked",
|
|
348
|
+
position: "0",
|
|
349
|
+
completed: false,
|
|
350
|
+
kind_data: { task_sync: { mark: "1" } },
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
[{ content: "Deploy the fix", status: "pending" }],
|
|
342
354
|
"claude-code",
|
|
343
355
|
);
|
|
344
356
|
const update = plan.toUpdate.find((u) => u.id === "row-1");
|
|
@@ -30,11 +30,40 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
|
30
30
|
import { spawn } from "@teamscala/os/spawn";
|
|
31
31
|
import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Where a tool's "latest" comes from.
|
|
35
|
+
*
|
|
36
|
+
* A DISCRIMINATED UNION, deliberately, rather than an optional `repo` field
|
|
37
|
+
* hanging off one shape: an unhandled variant then fails to compile instead of
|
|
38
|
+
* silently taking the npm branch. The previous single-member form
|
|
39
|
+
* (`type: "npm_package"`) made a GitHub-released binary UNREPRESENTABLE, not
|
|
40
|
+
* merely unregistered — which is why codex and zerostack are absent from
|
|
41
|
+
* `config/scala-singleton-tools` entirely. Both are standalone ELF binaries in
|
|
42
|
+
* ~/.local/bin and neither is npm-managed, so neither could be described at all.
|
|
43
|
+
*
|
|
44
|
+
* Their LOCATION is correct and must not be "fixed": `bun-bin-is-unstable`
|
|
45
|
+
* designates ${HOME}/.local/bin as the home for operator CLIs precisely so they
|
|
46
|
+
* never shadow a /usr/local/bin singleton wrapper. The gap was currency
|
|
47
|
+
* tracking, never placement.
|
|
48
|
+
*/
|
|
49
|
+
type VersionSource =
|
|
50
|
+
| {
|
|
51
|
+
type: "npm_package";
|
|
52
|
+
name: string;
|
|
53
|
+
dist_tag: string;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
/** A GitHub release asset — the shape codex and zerostack ship as. */
|
|
57
|
+
type: "github_release";
|
|
58
|
+
/** "owner/repo". */
|
|
59
|
+
repo: string;
|
|
60
|
+
/**
|
|
61
|
+
* Regex matched against each release asset's name to pick the download.
|
|
62
|
+
* Required: a repo publishing several platform builds has no safe default,
|
|
63
|
+
* and guessing one installs the wrong architecture silently.
|
|
64
|
+
*/
|
|
65
|
+
asset_pattern: string;
|
|
66
|
+
};
|
|
38
67
|
|
|
39
68
|
interface SingletonTool {
|
|
40
69
|
name: string;
|
|
@@ -98,9 +127,13 @@ function semverLt(a: string, b: string): boolean {
|
|
|
98
127
|
/** Read the installed version from a tool's real_path (symlink-resolved binary). */
|
|
99
128
|
async function readInstalledVersion(tool: SingletonTool): Promise<string | null> {
|
|
100
129
|
try {
|
|
101
|
-
|
|
130
|
+
const vs = tool.version_source;
|
|
131
|
+
// The package.json path only exists for an npm-sourced JS tool. A
|
|
132
|
+
// github_release tool has no node_modules entry, so it falls through to the
|
|
133
|
+
// `--version` probe below like any other binary.
|
|
134
|
+
if (tool.kind === "js" && tool.install_dir && vs?.type === "npm_package") {
|
|
102
135
|
// JS tools: read the installed package's package.json
|
|
103
|
-
const pkgJsonPath = `${tool.install_dir}/node_modules/${
|
|
136
|
+
const pkgJsonPath = `${tool.install_dir}/node_modules/${vs.name}/package.json`;
|
|
104
137
|
const pkg = await Bun.file(pkgJsonPath).json() as { version: string };
|
|
105
138
|
return pkg.version;
|
|
106
139
|
}
|
|
@@ -119,6 +152,30 @@ async function readInstalledVersion(tool: SingletonTool): Promise<string | null>
|
|
|
119
152
|
}
|
|
120
153
|
}
|
|
121
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Latest published version for a GitHub-released tool.
|
|
157
|
+
*
|
|
158
|
+
* Reads the repo's latest release tag rather than enumerating assets, because
|
|
159
|
+
* the tag is what carries the VERSION; the asset pattern only decides which
|
|
160
|
+
* build to download at install time. A leading "v" is stripped so the value
|
|
161
|
+
* compares against `readInstalledVersion` output under the same semver parser.
|
|
162
|
+
*/
|
|
163
|
+
async function fetchLatestGithubRelease(repo: string): Promise<string | null> {
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetch(
|
|
166
|
+
`https://api.github.com/repos/${repo}/releases/latest`,
|
|
167
|
+
{ headers: { accept: "application/vnd.github+json" } },
|
|
168
|
+
);
|
|
169
|
+
if (!res.ok) return null;
|
|
170
|
+
const release = (await res.json()) as { tag_name?: string };
|
|
171
|
+
const tag = (release.tag_name ?? "").trim().replace(/^v/, "");
|
|
172
|
+
if (!tag || !parseSemver(tag)) return null;
|
|
173
|
+
return tag;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
122
179
|
/** Fetch the latest version from npm for a given package + dist-tag. */
|
|
123
180
|
async function fetchLatestVersion(pkg: string, distTag: string): Promise<string | null> {
|
|
124
181
|
try {
|
|
@@ -153,7 +210,35 @@ async function fetchLatestVersion(pkg: string, distTag: string): Promise<string
|
|
|
153
210
|
/** Reinstall a stale tool to the latest version. */
|
|
154
211
|
async function reinstallTool(tool: SingletonTool, latest: string): Promise<boolean> {
|
|
155
212
|
const log = getAppLogger();
|
|
156
|
-
const vs = tool.version_source
|
|
213
|
+
const vs = tool.version_source;
|
|
214
|
+
if (!vs) return false;
|
|
215
|
+
|
|
216
|
+
if (vs.type === "github_release") {
|
|
217
|
+
// A GitHub-released binary is NOT installed by a package manager, so there
|
|
218
|
+
// is no "reinstall" verb to call — it would have to be downloaded, verified,
|
|
219
|
+
// made executable and swapped atomically over a file that may be RUNNING.
|
|
220
|
+
// That is a real install path with real failure modes (partial download,
|
|
221
|
+
// wrong architecture, replacing a binary mid-execution), and doing it badly
|
|
222
|
+
// is worse than not doing it: a half-written CLI is a broken CLI.
|
|
223
|
+
//
|
|
224
|
+
// So this reports the drift and stops. Detection is live from today; the
|
|
225
|
+
// automated swap is a separate, deliberate change. Reporting a version the
|
|
226
|
+
// watcher cannot actually install is honest — silently claiming currency
|
|
227
|
+
// would not be.
|
|
228
|
+
log.warn(
|
|
229
|
+
`[singleton-tools-currency] ${tool.name}: STALE, latest ${latest} from ${vs.repo}. Automated github_release install is not implemented — update it manually.`,
|
|
230
|
+
);
|
|
231
|
+
recordEvent({
|
|
232
|
+
kind: "singleton-tools.currency.tick",
|
|
233
|
+
payload: {
|
|
234
|
+
tool: tool.name,
|
|
235
|
+
latest,
|
|
236
|
+
status: "stale-manual-update-required",
|
|
237
|
+
repo: vs.repo,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
157
242
|
|
|
158
243
|
if (tool.kind === "js" && tool.install_dir) {
|
|
159
244
|
// JS tool: bun add in the install_dir
|
|
@@ -212,8 +297,21 @@ async function singletonToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig })
|
|
|
212
297
|
|
|
213
298
|
for (const tool of config.tools) {
|
|
214
299
|
const vs = tool.version_source;
|
|
215
|
-
if (!vs
|
|
216
|
-
//
|
|
300
|
+
if (!vs) {
|
|
301
|
+
// A tool with no version_source is NOT tracked, and that must be loud.
|
|
302
|
+
// This previously `continue`d silently as "not yet inside the mechanism",
|
|
303
|
+
// which is how a gap becomes permanent: nothing distinguishes a tool
|
|
304
|
+
// nobody has declared from one that is up to date, so the watcher reports
|
|
305
|
+
// a clean tick while the tool rots. That is the exact shape of
|
|
306
|
+
// `a-component-may-not-report-a-state-it-has-not-verified` — the run
|
|
307
|
+
// advertises coverage it never performed.
|
|
308
|
+
log.warn(
|
|
309
|
+
`[singleton-tools-currency] ${tool.name}: NO version_source — not tracked, cannot be kept current. Add one to config/scala-singleton-tools.`,
|
|
310
|
+
);
|
|
311
|
+
recordEvent({
|
|
312
|
+
kind: "singleton-tools.currency.tick",
|
|
313
|
+
payload: { tool: tool.name, status: "no-version-source" },
|
|
314
|
+
});
|
|
217
315
|
continue;
|
|
218
316
|
}
|
|
219
317
|
|
|
@@ -236,7 +334,12 @@ async function singletonToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig })
|
|
|
236
334
|
continue;
|
|
237
335
|
}
|
|
238
336
|
|
|
239
|
-
|
|
337
|
+
// Branch on the discriminant, never on a field's presence — an unhandled
|
|
338
|
+
// variant is a compile error here rather than a silent fall-through to npm.
|
|
339
|
+
const latest =
|
|
340
|
+
vs.type === "npm_package"
|
|
341
|
+
? await fetchLatestVersion(vs.name, vs.dist_tag)
|
|
342
|
+
: await fetchLatestGithubRelease(vs.repo);
|
|
240
343
|
if (!latest) {
|
|
241
344
|
log.warn(`[singleton-tools-currency] ${tool.name}: cannot fetch latest — skip`);
|
|
242
345
|
recordEvent({
|