@sigrun/sigrun 0.0.0-stage → 0.1.0

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.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Interaction + ambient seam routes (docs/approval-ux-seams.md): the
3
+ * pending-interaction list, the ONE generic answer route, and the
4
+ * conversation's ambient surfaces.
5
+ *
6
+ * The polling surfaces (`GET …/interactions`, `GET …/ambient`) take NO
7
+ * conversation pre-handler — the polling convention (codified on
8
+ * `loadConversation` in `conversation-handler.ts`): a channel polling a
9
+ * torn-down conversation must not error, so they read the seam index
10
+ * docs directly and answer 200 with an empty list for an absent
11
+ * conversation. They are still §5.9 `:id` routes, so each handler applies
12
+ * the VISIBILITY half of the access rule itself (`visibleTo`): a caller
13
+ * with `accessOf === "none"` gets the hidden-is-404
14
+ * `conversation_not_found`, exactly as the pre-handler would give (read
15
+ * access suffices — these routes can never write).
16
+ *
17
+ * The answer route is a write: it resolves its conversation through the
18
+ * `loadConversation(ctx, "write")` pre-handler like every other write
19
+ * `:id` route, with a params schema covering BOTH `id` and `callId` so
20
+ * Fastify's param validation cannot mask the lookup. A human answer
21
+ * stamps `answeredByUser` with the principal's user (spec §5.9: who
22
+ * answered matters to every participant a conversation is shared with).
23
+ *
24
+ * The cross-conversation inbox (`GET /v1/interactions?status=pending`,
25
+ * spec §7.5) also lives here. It has NO `:id`, so no pre-handler — it
26
+ * snapshots the session-singleton `InteractionsIndexDoc` once per
27
+ * request (seed/answer/expire commits maintain it; there is no cache to
28
+ * invalidate), then applies the visibility half of the access rule per
29
+ * conversation (`accessOf` against its SessionMeta — hidden is ABSENT
30
+ * from the list, the list-side half of §5.9's hidden-is-404) before
31
+ * reading each pending record. This is how "answer the agent from my
32
+ * phone" works without opening each conversation; answers still go
33
+ * through the per-conversation answer route above.
34
+ */
35
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
36
+ import { AmbientDoc, AmbientOwnersDoc, } from "../../harness/ambient.js";
37
+ import { InteractionDoc, InteractionsIndexDoc, applyAnswer, indexSettled, } from "../../harness/interactions.js";
38
+ import { loadConversation } from "../conversation-handler.js";
39
+ import { ID_PARAMS_SCHEMA, conversationId } from "../helpers.js";
40
+ import { accessOf } from "@sigrun/shared/access";
41
+ import { isSuperuser } from "../auth/principal.js";
42
+ import { SessionMetaDoc } from "../session-meta.js";
43
+ /**
44
+ * Path params for the answer route — `ID_PARAMS_SCHEMA`'s shape plus the
45
+ * `callId` segment its address carries. Declaring BOTH params keeps
46
+ * Fastify's param validation from masking the pre-handler's lookup: a
47
+ * malformed id is a 400 like every other `:id` route.
48
+ */
49
+ const ANSWER_PARAMS_SCHEMA = {
50
+ type: "object",
51
+ properties: { id: { type: "integer" }, callId: { type: "string" } },
52
+ required: ["id", "callId"],
53
+ additionalProperties: false,
54
+ };
55
+ /**
56
+ * The §5.9 visibility half of the access rule for the pre-handler-free
57
+ * polling GETs: resolve the conversation's session-meta and answer
58
+ * whether the caller may SEE it (`accessOf !== "none"`). Absent doc →
59
+ * defaults → ownerless → visible. These routes are read-only by
60
+ * construction, so read/write need never be distinguished here.
61
+ */
62
+ async function visibleTo(ctx, convId, principal) {
63
+ const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, convId, BACKGROUND_CONTEXT);
64
+ return (accessOf({ user: principal.user, superuser: isSuperuser(principal) }, meta ?? {}) !== "none");
65
+ }
66
+ /** Register the interaction-seam and ambient-surface routes. */
67
+ export function registerInteractionRoutes(app, ctx) {
68
+ const { opened } = ctx;
69
+ // ─── Interaction seam (docs/approval-ux-seams.md) ────────────────────
70
+ // One generic surface shared by every extension: pending interactions
71
+ // (list) and answers (the ONE generic route). Resolution semantics stay
72
+ // extension-owned — the route only writes the envelope's answer field.
73
+ // ─── Cross-conversation inbox (spec §7.5) ──────────────────────
74
+ // ONE list over every conversation the caller can see: a single
75
+ // snapshot of the session-singleton index (seed/answer/expire commits
76
+ // maintain it — nothing to invalidate), §5.9 visibility per
77
+ // conversation (hidden is ABSENT, the list-side half of the rule),
78
+ // then the still-pending records. `pending` is the only indexed
79
+ // status: any other query value is a fail-loud 400 (absent means
80
+ // pending).
81
+ app.get("/v1/interactions", async (request, reply) => {
82
+ const query = request.query;
83
+ if (query.status !== undefined && query.status !== "pending") {
84
+ return await reply
85
+ .code(400)
86
+ .send({ error: "invalid_field", field: "status" });
87
+ }
88
+ const index = await opened.harness.snapshot(InteractionsIndexDoc, BACKGROUND_CONTEXT);
89
+ const pending = index?.pending ?? {};
90
+ const interactions = [];
91
+ for (const key of Object.keys(pending)) {
92
+ const id = Number(key);
93
+ const convId = conversationId(id);
94
+ // §5.9 visibility, applied in-handler (a no-`:id` route has no
95
+ // pre-handler): a hidden conversation contributes nothing.
96
+ if (!(await visibleTo(ctx, convId, request.principal))) {
97
+ continue;
98
+ }
99
+ for (const callId of pending[key] ?? []) {
100
+ const record = await opened.harness.snapshot(InteractionDoc, convId, callId, BACKGROUND_CONTEXT);
101
+ // The index is maintained in the same commits as the records,
102
+ // but a raced settle can leave a stale entry — serve only
103
+ // what still reads pending (the per-conversation list's guard).
104
+ if (record !== undefined && record.status === "pending") {
105
+ interactions.push({ ...record, conversationId: id });
106
+ }
107
+ }
108
+ }
109
+ interactions.sort((a, b) => a.createdAt - b.createdAt);
110
+ return { interactions };
111
+ });
112
+ /** The list route's wire record: envelope + body + details, unmodified. */
113
+ app.get("/v1/conversations/:id/interactions", { schema: { params: ID_PARAMS_SCHEMA } }, async (request, reply) => {
114
+ const { id } = request.params;
115
+ const convId = conversationId(id);
116
+ // §5.9 visibility (the pre-handler-free polling convention means
117
+ // the rule is applied HERE): hidden → 404 conversation_not_found.
118
+ if (!(await visibleTo(ctx, convId, request.principal))) {
119
+ return await reply
120
+ .code(404)
121
+ .send({ error: "conversation_not_found" });
122
+ }
123
+ // Absent conversation → empty list (the ambient list's convention:
124
+ // a channel polling a torn-down conversation must not error).
125
+ const index = await opened.harness.snapshot(InteractionsIndexDoc, BACKGROUND_CONTEXT);
126
+ const pending = index?.pending[String(convId)] ?? [];
127
+ const records = [];
128
+ for (const callId of pending) {
129
+ const record = await opened.harness.snapshot(InteractionDoc, convId, callId, BACKGROUND_CONTEXT);
130
+ if (record !== undefined && record.status === "pending") {
131
+ records.push(record);
132
+ }
133
+ }
134
+ return { interactions: records };
135
+ });
136
+ // The ONE generic answer route: writes `answer` into the envelope with
137
+ // first-answer-wins inside the commit. 404 for unknown/not-pending;
138
+ // 400 for a malformed answer. `by` is "human" (the only v1 caller).
139
+ app.post("/v1/conversations/:id/interactions/:callId/answer", {
140
+ schema: { params: ANSWER_PARAMS_SCHEMA },
141
+ preHandler: loadConversation(ctx, "write"),
142
+ }, async (request, reply) => {
143
+ const { callId } = request.params;
144
+ const conversation = request.conversation;
145
+ const body = request.body;
146
+ // SAFETY: the spread of an explicitly-undefined InteractionAnswer
147
+ // builds an empty mutable scaffold — every field is optional, and
148
+ // each is set only after its body value passed validation, so the
149
+ // loose local type never leaks an unvalidated field.
150
+ const answer = {
151
+ ...undefined,
152
+ };
153
+ if (body !== undefined && body !== null) {
154
+ if (typeof body !== "object" || Array.isArray(body)) {
155
+ return await reply.code(400).send({ error: "invalid_body" });
156
+ }
157
+ const b = body;
158
+ const known = new Set(["ids", "values", "text"]);
159
+ const unknownKey = Object.keys(b).find((k) => !known.has(k));
160
+ if (unknownKey !== undefined) {
161
+ return await reply
162
+ .code(400)
163
+ .send({ error: "invalid_field", field: unknownKey });
164
+ }
165
+ if (b.ids !== undefined) {
166
+ if (!Array.isArray(b.ids) ||
167
+ b.ids.some((v) => typeof v !== "string")) {
168
+ return await reply
169
+ .code(400)
170
+ .send({ error: "invalid_body" });
171
+ }
172
+ answer.ids = b.ids;
173
+ // (mutable local; the wire type stays readonly)
174
+ }
175
+ if (b.values !== undefined) {
176
+ if (typeof b.values !== "object" || b.values === null) {
177
+ return await reply.code(400).send({ error: "invalid_body" });
178
+ }
179
+ const values = {};
180
+ for (const [k, v] of Object.entries(b.values)) {
181
+ if (typeof v !== "string" &&
182
+ typeof v !== "number" &&
183
+ typeof v !== "boolean") {
184
+ return await reply
185
+ .code(400)
186
+ .send({ error: "invalid_field", field: `values.${k}` });
187
+ }
188
+ values[k] = v;
189
+ }
190
+ answer.values = values;
191
+ }
192
+ if (b.text !== undefined) {
193
+ if (typeof b.text !== "string") {
194
+ return await reply.code(400).send({ error: "invalid_body" });
195
+ }
196
+ answer.text = b.text;
197
+ }
198
+ }
199
+ // The commit returns a plain outcome; reply shaping happens
200
+ // AFTER the commit (a Fastify reply must never travel through
201
+ // the harness mutation line).
202
+ const committed = await conversation.commit(async (tx) => {
203
+ const draft = await tx.doc(InteractionDoc, conversation.id, callId, callId);
204
+ // Unknown callId: the doc materializes empty — detect via kind
205
+ // never having been seeded (an empty seed has kind "").
206
+ if (draft.kind === "")
207
+ return { found: false };
208
+ const applied = applyAnswer(draft, answer, "human", Date.now(), request.principal.user);
209
+ if (!applied)
210
+ return { found: true, applied: false };
211
+ // NEVER re-read a mutated draft field (even before the next
212
+ // await): reading a newly-set nested value off the tracked
213
+ // draft trips chord's settled-overlay check. The response
214
+ // projects the INPUT answer — identical to what was written.
215
+ const index = await tx.doc(InteractionsIndexDoc);
216
+ indexSettled(index, conversation.id, callId);
217
+ return {
218
+ found: true,
219
+ applied: true,
220
+ answer,
221
+ };
222
+ }, BACKGROUND_CONTEXT);
223
+ if (!committed.found) {
224
+ return await reply.code(404).send({ error: "no_such_interaction" });
225
+ }
226
+ if (!committed.applied) {
227
+ return await reply.code(404).send({ error: "not_pending" });
228
+ }
229
+ return {
230
+ callId,
231
+ status: "answered",
232
+ answer: committed.answer,
233
+ };
234
+ });
235
+ // ─── Ambient seam ────────────────────────────────────────────────────
236
+ // The conversation's ambient surfaces, replacement-state reads.
237
+ app.get("/v1/conversations/:id/ambient", { schema: { params: ID_PARAMS_SCHEMA } }, async (request, reply) => {
238
+ const { id } = request.params;
239
+ const convId = conversationId(id);
240
+ // §5.9 visibility, applied in-handler like the interactions list.
241
+ if (!(await visibleTo(ctx, convId, request.principal))) {
242
+ return await reply
243
+ .code(404)
244
+ .send({ error: "conversation_not_found" });
245
+ }
246
+ const index = await opened.harness.snapshot(AmbientOwnersDoc, BACKGROUND_CONTEXT);
247
+ const keys = Object.keys(index?.owners ?? {});
248
+ const surfaces = [];
249
+ for (const key of keys) {
250
+ const surface = await opened.harness.snapshot(AmbientDoc, convId, key, BACKGROUND_CONTEXT);
251
+ // A surface owned but never written in THIS conversation is
252
+ // absent (owner registration is session-global; surfaces are
253
+ // conversation-scoped).
254
+ if (surface !== undefined && surface.body !== undefined) {
255
+ surfaces.push(surface);
256
+ }
257
+ }
258
+ return { ambient: surfaces };
259
+ });
260
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Jobs Task 4 (spec §8.4 + §5.9): the `/v1/jobs` HTTP surface over
3
+ * Task 3's JobsService.
4
+ *
5
+ * ACCESS (§5.9, the conversations rule applied to job records): every
6
+ * route resolves `accessOf(principal, {owner, sharing})` against the
7
+ * job RECORD — hidden is 404 `job_not_found` on every `:id` route and
8
+ * absence from the list (no 403 tell); a read-only caller on a write
9
+ * route gets 403. The owner is server-stamped at create
10
+ * (`principal.user`; a body-carried `owner` is stripped) and moves only
11
+ * through the transfer path; `sharing` is the PATCH share surface
12
+ * (mirrored onto the record from the merged spec, exactly like a
13
+ * declared job).
14
+ *
15
+ * The HISTORY routes (`runs`/`revisions`) answer for a RETIRED job too,
16
+ * until retention trims its records — access is evaluated against the
17
+ * newest REVISION's owner/sharing then (the record is gone with the
18
+ * retire). `GET /v1/jobs/:id` itself requires a live record.
19
+ *
20
+ * DECLARED jobs (`file:`/`ext:` ids) are immutable: PATCH and DELETE
21
+ * return 409 `job_declared` (edit the source instead). Run-now honors
22
+ * the caller's write access and the job's own `overlap`.
23
+ */
24
+ import { accessOf } from "@sigrun/shared/access";
25
+ import { isSuperuser } from "../auth/principal.js";
26
+ import { JobDeclaredError, JobNotFoundError } from "../../jobs/index.js";
27
+ import { validateJobSpec } from "../../jobs/spec.js";
28
+ /** The principal form `accessOf` takes (§5.9). */
29
+ function accessPrincipal(request) {
30
+ return {
31
+ user: request.principal.user,
32
+ superuser: isSuperuser(request.principal),
33
+ };
34
+ }
35
+ /** The §5.9 resource form ({owner?, sharing?}) of a job row. */
36
+ function resourceOf(row) {
37
+ return {
38
+ ...(row.owner === undefined ? {} : { owner: row.owner }),
39
+ ...(row.sharing === undefined ? {} : { sharing: row.sharing }),
40
+ };
41
+ }
42
+ /**
43
+ * The shared `:id` error mapping: unknown/hidden → 404 `job_not_found`;
44
+ * visible-but-readonly on a write route → 403 (the callers resolve
45
+ * access themselves; this only serves the two 404/403 replies).
46
+ */
47
+ async function deny(reply, error) {
48
+ if (error === "forbidden") {
49
+ return await reply.code(403).send({ error: "forbidden" });
50
+ }
51
+ return await reply.code(404).send({ error: "job_not_found" });
52
+ }
53
+ /** The service's domain errors → the route's codes (409/404). */
54
+ async function domainError(reply, error) {
55
+ if (error instanceof JobDeclaredError) {
56
+ return await reply.code(409).send({ error: "job_declared" });
57
+ }
58
+ if (error instanceof JobNotFoundError) {
59
+ return await reply.code(404).send({ error: "job_not_found" });
60
+ }
61
+ throw error;
62
+ }
63
+ /** Register the `/v1/jobs` routes. */
64
+ export function registerJobRoutes(app, ctx) {
65
+ const { opened } = ctx;
66
+ const jobs = opened.jobs;
67
+ /**
68
+ * Resolve one job for the access rule: the live record's row when it
69
+ * exists; for a RETIRED job (the history routes' case) the newest
70
+ * revision's shape. `undefined` only when neither exists (unknown id).
71
+ */
72
+ const resolve = async (request, id) => {
73
+ const principal = accessPrincipal(request);
74
+ const wire = await jobs.get(id);
75
+ if (wire !== undefined) {
76
+ const { access: _ignored, ...row } = wire;
77
+ return { row, access: accessOf(principal, resourceOf(row)) };
78
+ }
79
+ // Retired: the newest revision answers until retention trims it.
80
+ const [newest] = await jobs.revisions(id);
81
+ if (newest === undefined)
82
+ return undefined;
83
+ const row = {
84
+ id,
85
+ name: newest.spec.name,
86
+ source: newest.source,
87
+ ...(newest.owner === undefined ? {} : { owner: newest.owner }),
88
+ ...(newest.sharing === undefined ? {} : { sharing: newest.sharing }),
89
+ schedule: newest.spec.schedule,
90
+ action: newest.spec.action,
91
+ enabled: newest.enabled,
92
+ version: newest.version,
93
+ };
94
+ return {
95
+ // The retired row's status went with the record; the nudge only
96
+ // needs identity + definition (clients refetch the list).
97
+ row: { ...row, state: "disabled" },
98
+ access: accessOf(principal, resourceOf(row)),
99
+ };
100
+ };
101
+ /** The read guard: unknown/hidden → the 404; the row otherwise. */
102
+ const visible = async (request, reply, id) => {
103
+ const resolved = await resolve(request, id);
104
+ if (resolved === undefined || resolved.access === "none") {
105
+ await deny(reply, "job_not_found");
106
+ return { denied: true };
107
+ }
108
+ return resolved;
109
+ };
110
+ // GET /v1/jobs — the caller-visible rows (hidden absent, access stamped).
111
+ app.get("/v1/jobs", async (request) => {
112
+ const principal = accessPrincipal(request);
113
+ const visibleJobs = [];
114
+ for (const row of await jobs.list()) {
115
+ const access = accessOf(principal, resourceOf(row));
116
+ if (access === "none")
117
+ continue;
118
+ visibleJobs.push({ ...row, access });
119
+ }
120
+ return { jobs: visibleJobs };
121
+ });
122
+ // GET /v1/jobs/:id — a live record only (history routes cover retired).
123
+ app.get("/v1/jobs/:id", async (request, reply) => {
124
+ const { id } = request.params;
125
+ const wire = await jobs.get(id);
126
+ if (wire === undefined) {
127
+ return await deny(reply, "job_not_found");
128
+ }
129
+ const access = accessOf(accessPrincipal(request), resourceOf(wire));
130
+ if (access === "none") {
131
+ return await deny(reply, "job_not_found");
132
+ }
133
+ return { job: { ...wire, access } };
134
+ });
135
+ // POST /v1/jobs — create (owner = the caller). Validation rejections
136
+ // carry the offending field path (the shared §6 error vocabulary).
137
+ app.post("/v1/jobs", async (request, reply) => {
138
+ const validated = validateJobSpec(request.body);
139
+ if (!validated.ok) {
140
+ return await reply
141
+ .code(400)
142
+ .send({ error: "invalid_field", field: validated.field });
143
+ }
144
+ // The owner is server-stamped (§5.9): a body-carried `spec.owner`
145
+ // is inert for api jobs — strip it so the record's spec and the
146
+ // stamped owner can never disagree.
147
+ const spec = { ...validated.spec };
148
+ delete spec.owner;
149
+ const job = await jobs.createApi(spec, request.principal.user);
150
+ return await reply.code(201).send({ job: { ...job, access: "write" } });
151
+ });
152
+ // PATCH /v1/jobs/:id — write access; the MERGED spec validates whole.
153
+ app.patch("/v1/jobs/:id", async (request, reply) => {
154
+ const { id } = request.params;
155
+ const resolved = await visible(request, reply, id);
156
+ if ("denied" in resolved)
157
+ return;
158
+ if (resolved.access !== "write") {
159
+ return await deny(reply, "forbidden");
160
+ }
161
+ const patch = request.body;
162
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
163
+ return await reply
164
+ .code(400)
165
+ .send({ error: "invalid_field", field: "spec" });
166
+ }
167
+ // §5.9's server-stamped posture: `owner` never moves through a
168
+ // patch (the transfer path owns it) — the conversations rule.
169
+ if ("owner" in patch) {
170
+ return await reply
171
+ .code(400)
172
+ .send({ error: "invalid_field", field: "owner" });
173
+ }
174
+ // Validate the merged spec: a partial patch that breaks the whole
175
+ // is a 400, never a half-applied write. The route merges over the
176
+ // WIRE row minus its record-`owner` key (a wire `owner` would
177
+ // launder through as `spec.owner` — the stamped owner is preserved
178
+ // by the service); `overlap`/`catchUp` ride from the stored spec
179
+ // inside the service (validated at their own writes), and a patch
180
+ // renaming the job carries its own validated name.
181
+ const { owner: _recordOwner, ...base } = resolved.row;
182
+ const merged = validateJobSpec({
183
+ ...base,
184
+ ...patch,
185
+ });
186
+ if (!merged.ok) {
187
+ return await reply
188
+ .code(400)
189
+ .send({ error: "invalid_field", field: merged.field });
190
+ }
191
+ try {
192
+ // Pass the VALIDATED rebuilt spec (only declared, validated keys
193
+ // survive validateJobSpec's rebuild): a body carrying unknown keys
194
+ // (`id`, `state`, …) can never smuggle them into the stored spec.
195
+ // The service's own spread over the prior spec keeps prior fields
196
+ // the patch left absent (`overlap`/`catchUp`).
197
+ const job = await jobs.updateApi(id, merged.spec, request.principal.user ?? "api");
198
+ return { job: { ...job, access: "write" } };
199
+ }
200
+ catch (error) {
201
+ return await domainError(reply, error);
202
+ }
203
+ });
204
+ // DELETE /v1/jobs/:id — write access; api jobs only.
205
+ app.delete("/v1/jobs/:id", async (request, reply) => {
206
+ const { id } = request.params;
207
+ const resolved = await visible(request, reply, id);
208
+ if ("denied" in resolved)
209
+ return;
210
+ if (resolved.access !== "write") {
211
+ return await deny(reply, "forbidden");
212
+ }
213
+ try {
214
+ await jobs.deleteApi(id, request.principal.user ?? "api");
215
+ return await reply.code(204).send();
216
+ }
217
+ catch (error) {
218
+ return await domainError(reply, error);
219
+ }
220
+ });
221
+ // POST /v1/jobs/:id/run — write access; fires now (overlap honored).
222
+ // A LIVE record is required (like PATCH/DELETE): a retired job's
223
+ // revision never satisfies run-now — 404, never fireJob's blocked-200
224
+ // (a deleted job must not be re-triggerable through its history tail).
225
+ app.post("/v1/jobs/:id/run", async (request, reply) => {
226
+ const { id } = request.params;
227
+ const wire = await jobs.get(id);
228
+ if (wire === undefined) {
229
+ return await deny(reply, "job_not_found");
230
+ }
231
+ const access = accessOf(accessPrincipal(request), resourceOf(wire));
232
+ if (access === "none") {
233
+ return await deny(reply, "job_not_found");
234
+ }
235
+ if (access === "read") {
236
+ return await deny(reply, "forbidden");
237
+ }
238
+ return await jobs.runNow(id);
239
+ });
240
+ // GET /v1/jobs/:id/runs?limit= — newest first; answers for retired
241
+ // jobs (the revision-resolved access) until retention trims them.
242
+ app.get("/v1/jobs/:id/runs", async (request, reply) => {
243
+ const { id } = request.params;
244
+ const resolved = await visible(request, reply, id);
245
+ if ("denied" in resolved)
246
+ return;
247
+ // limit: default 20, a positive integer, capped at jobHistory.runs
248
+ // (read live — a settings PATCH reaches the next call).
249
+ const query = request.query;
250
+ const cap = opened.settings.read().jobHistory?.runs ?? 50;
251
+ let limit = 20;
252
+ if (query.limit !== undefined) {
253
+ const parsed = Number(query.limit);
254
+ if (!Number.isInteger(parsed) || parsed <= 0) {
255
+ return await reply
256
+ .code(400)
257
+ .send({ error: "invalid_field", field: "limit" });
258
+ }
259
+ limit = Math.min(parsed, cap);
260
+ }
261
+ return { runs: await jobs.runs(id, limit) };
262
+ });
263
+ // GET /v1/jobs/:id/revisions — newest first; same retired-job rule.
264
+ app.get("/v1/jobs/:id/revisions", async (request, reply) => {
265
+ const { id } = request.params;
266
+ const resolved = await visible(request, reply, id);
267
+ if ("denied" in resolved)
268
+ return;
269
+ return { revisions: await jobs.revisions(id) };
270
+ });
271
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Boot-fact routes: `GET /v1/meta` (the real health aggregation + seam
3
+ * inventory + collision record) and `GET /v1/commands` (the slash-command
4
+ * catalog). Both are pure derivations over boot-retained state — a client
5
+ * may cache them.
6
+ */
7
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
8
+ import { aggregateHealth } from "../../harness/health.js";
9
+ import { isBackupMarkerStale, readBackupMarker } from "../../backup/marker.js";
10
+ import { expandableTemplates } from "../../commands/expand.js";
11
+ import { discoverTemplates } from "../../commands/templates.js";
12
+ import { listSkills } from "../../skills/index.js";
13
+ import { expandCommandEntries } from "../commands.js";
14
+ import { HARNESS_VERSION, SCHEMA_VERSION } from "../schema-fixture.js";
15
+ /** Register `GET /v1/meta` + `GET /v1/commands`. */
16
+ export function registerMetaRoutes(app, ctx) {
17
+ const { opened, config, webuiBundle, commandCatalog } = ctx;
18
+ app.get("/v1/meta", async () => {
19
+ // Idle-worker projection: re-derived per request (statuses change
20
+ // over the process lifetime); quarantined extensions never
21
+ // registered, so a name miss just yields no workers.
22
+ const workerStatuses = opened.workerStatuses();
23
+ // §6.8 backup honesty: the last-success marker is read PER REQUEST
24
+ // from config.stateDir (the CronJob is a separate process — the only
25
+ // fresh-enough view is the filesystem). Unconfigured stays
26
+ // presence-derived; configured degrades when the marker is absent,
27
+ // corrupt, or older than server.backup.staleAfterHours (default 36 h).
28
+ const backupConfigured = config.backup !== undefined;
29
+ const marker = backupConfigured
30
+ ? await readBackupMarker(config.stateDir)
31
+ : undefined;
32
+ const backupHealth = !backupConfigured
33
+ ? "unconfigured"
34
+ : marker === undefined ||
35
+ isBackupMarkerStale(marker, config.backupStaleAfterHours)
36
+ ? "degraded"
37
+ : "configured";
38
+ return {
39
+ harnessVersion: HARNESS_VERSION,
40
+ schemaVersion: SCHEMA_VERSION,
41
+ extensions: opened.extensionOutcomes.map((outcome) => {
42
+ const workers = workerStatuses.get(outcome.name);
43
+ return {
44
+ name: outcome.name,
45
+ status: outcome.status,
46
+ ...(outcome.error === undefined ? {} : { error: outcome.error }),
47
+ ...(workers === undefined || workers.length === 0
48
+ ? {}
49
+ : { workers }),
50
+ };
51
+ }),
52
+ // §5 health: pure aggregation over boot-retained §3 producers —
53
+ // memory from the memory extension's producer ("ok" for the no-op
54
+ // default — quarantines surface via extensions[].status, never
55
+ // double-reported here), providers aggregated across
56
+ // contributors (empty → "ok"), and backup from the §6.8 marker
57
+ // derivation above ("configured" only while a fresh success
58
+ // exists — the one storage read this route makes).
59
+ health: {
60
+ backup: backupHealth,
61
+ memory: opened.memoryHealth?.() ?? "ok",
62
+ providers: aggregateHealth(opened.providerHealth),
63
+ },
64
+ // The auth block (W1 Task 6, spec §5.7): the effective method list
65
+ // (resolved once in `createServer`) + the onboarding state — both
66
+ // pure derivations, never per-request negotiation.
67
+ auth: {
68
+ methods: ctx.methods,
69
+ claimed: opened.identity.users.isClaimed(),
70
+ },
71
+ // §6.8: the marker's timestamp — when the last successful backup
72
+ // ran, whenever one is recorded against a configured target (a
73
+ // STALE marker still surfaces: "when was the last success" is
74
+ // exactly what a degraded badge begs for).
75
+ backup: marker === undefined
76
+ ? {}
77
+ : { lastSuccessAt: marker.at },
78
+ // §5 capabilities: backup affordances exist iff a target is
79
+ // configured (the badge, not an error, when absent).
80
+ capabilities: {
81
+ backup: config.backup !== undefined,
82
+ // §4.1/§6 channels: the SELECTED bundle's name ("webui" for the
83
+ // built-in default and explicit paths; a contributed selection's own
84
+ // name) exactly while the channel bundle is mounted; an API-only
85
+ // deploy (gate off or absent default) reports [].
86
+ channels: webuiBundle === undefined ? [] : [webuiBundle.channel],
87
+ },
88
+ // The seam inventory: declaration-derived, no doc reads.
89
+ seam: {
90
+ interactions: [...opened.seamKinds]
91
+ .filter(([, kinds]) => (kinds.interaction?.length ?? 0) > 0)
92
+ .map(([name, kinds]) => ({
93
+ extension: name,
94
+ kinds: [...(kinds.interaction ?? [])],
95
+ })),
96
+ ambient: [...opened.seamKinds]
97
+ .filter(([, kinds]) => (kinds.ambient?.length ?? 0) > 0)
98
+ .map(([name, kinds]) => ({
99
+ extension: name,
100
+ keys: [...(kinds.ambient ?? [])],
101
+ })),
102
+ },
103
+ // Boot-merge collisions: name collisions among
104
+ // extension-contributed webui bundles, the provider-merge sink,
105
+ // and extension command names (W5 Task 3). Last-registered-wins
106
+ // everywhere; the record is operator visibility, never an error
107
+ // (a core-name command collision is dropped + warned at build,
108
+ // deliberately absent here).
109
+ collisions: {
110
+ webui: opened.webuiBundleCollisions,
111
+ providers: opened.providerCollisions,
112
+ commands: commandCatalog.collisions,
113
+ },
114
+ };
115
+ });
116
+ // The slash-command catalog: the thin metadata list the composer's `/`
117
+ // menu consumes — name, description, args hint, and the endpoint each
118
+ // command drives (pi parity: thin metadata over the endpoints the other
119
+ // routes serve). Since W5 Task 3 this is the MERGED catalog (the core
120
+ // table + extension entries, each carrying its `extension` slug and
121
+ // invoke endpoint). Since Task 2c the response is built PER REQUEST:
122
+ // the trailing `kind: "expand"` entries (every discovered skill as
123
+ // `skill:<name>`, plus the templates) are re-scanned live — skills and
124
+ // templates appear the moment their files land, no reload command. The
125
+ // core + extension halves remain boot constants; see `commands.ts` for
126
+ // the scope rulings.
127
+ app.get("/v1/commands", async () => {
128
+ // The workspace root (the first of the three) reads through a
129
+ // workspace env, exactly like skill discovery at generation time;
130
+ // the catalog scan uses the root conversation's env — the static
131
+ // workspace every conversation resolves to today.
132
+ const expansion = opened.commandExpansion;
133
+ const env = await opened.envBuilder({ conversationId: ctx.rootId, read: opened.harness }, BACKGROUND_CONTEXT);
134
+ if (env === undefined) {
135
+ throw new Error("envBuilder returned no ExecutionEnv");
136
+ }
137
+ const skills = await listSkills(env, BACKGROUND_CONTEXT, expansion.skills);
138
+ const templates = expandableTemplates(await discoverTemplates(env, BACKGROUND_CONTEXT, expansion.templates), expansion.commandNames());
139
+ return {
140
+ commands: [
141
+ ...commandCatalog.commands,
142
+ ...expandCommandEntries(skills, templates),
143
+ ],
144
+ };
145
+ });
146
+ }