@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.
- package/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- 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
|
+
}
|