@sigrun/sigrun 0.5.0 → 0.6.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.
@@ -28,6 +28,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
28
28
  import { JobDoc, listRevisions, listRuns, pruneHistory, readJob, scanJobs, writeJobChange, } from "./docs.js";
29
29
  import { fireJob } from "./fire.js";
30
30
  import { reconcileJobs, } from "./reconcile.js";
31
+ import { jobStateDir, removeJobState } from "./state-dir.js";
31
32
  import { specHash } from "./spec.js";
32
33
  /** Thrown when an API write targets a `file:`/`ext:` job id (409 `job_declared`). */
33
34
  export class JobDeclaredError extends Error {
@@ -53,13 +54,15 @@ function historyLimits(settings) {
53
54
  }
54
55
  /** The wire row of a record (access left for the caller: the ROUTE
55
56
  * computes it per principal; the raw row carries the neutral default).
56
- * Exported for boot's `job.changed` publication (W4 Task 4). */
57
- export function jobWireOf(id, record) {
57
+ * Exported for boot's `job.changed` publication (W4 Task 4). The
58
+ * effective owner is resolved by the caller; the stored record and its
59
+ * spec hash remain untouched (spec §6.5). */
60
+ export function jobWireOf(id, record, owner) {
58
61
  return {
59
62
  id,
60
63
  name: record.spec.name,
61
64
  source: record.source,
62
- ...(record.owner === undefined ? {} : { owner: record.owner }),
65
+ ...(owner === undefined ? {} : { owner }),
63
66
  ...(record.sharing === undefined
64
67
  ? {}
65
68
  : { sharing: JSON.parse(JSON.stringify(record.sharing)) }),
@@ -82,6 +85,17 @@ export function jobWireOf(id, record) {
82
85
  export function createJobsService(deps) {
83
86
  const ctx = BACKGROUND_CONTEXT;
84
87
  const limits = () => historyLimits(deps.settings);
88
+ const wireOf = (id, record) => jobWireOf(id, record, deps.effectiveOwner(record.owner));
89
+ const stateRoot = (owner) => deps.jobsRoot?.(owner);
90
+ const removeStateBestEffort = async (root, id) => {
91
+ try {
92
+ await removeJobState(root, id);
93
+ }
94
+ catch (error) {
95
+ const path = root === undefined ? id : jobStateDir(root, id);
96
+ console.warn(`jobs: could not remove job state ${path}: ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ };
85
99
  // `job.changed` observation (W4 Task 4): ONE commit subscription over
86
100
  // the `sigrun.job` family invokes the injected hook after EVERY record
87
101
  // change — the service's own API writes, the executor's status patches
@@ -114,15 +128,16 @@ export function createJobsService(deps) {
114
128
  runtime: deps.runtime,
115
129
  rootId: deps.rootId,
116
130
  sources: deps.sources,
131
+ ...(deps.jobsRoot === undefined ? {} : { jobsRoot: deps.jobsRoot }),
117
132
  }, ctx);
118
133
  return {
119
134
  async list() {
120
135
  const records = await scanJobs(deps.storage, ctx);
121
- return records.map(({ id, record }) => jobWireOf(id, record));
136
+ return records.map(({ id, record }) => wireOf(id, record));
122
137
  },
123
138
  async get(id) {
124
139
  const record = await readJob(deps.harness, id, ctx);
125
- return record === undefined ? undefined : jobWireOf(id, record);
140
+ return record === undefined ? undefined : wireOf(id, record);
126
141
  },
127
142
  async createApi(spec, owner) {
128
143
  const id = `api:${crypto.randomUUID()}`;
@@ -150,7 +165,7 @@ export function createJobsService(deps) {
150
165
  const record = await readJob(deps.harness, id, ctx);
151
166
  if (record === undefined)
152
167
  throw new JobNotFoundError(id);
153
- return jobWireOf(id, record);
168
+ return wireOf(id, record);
154
169
  },
155
170
  async updateApi(id, patch, changedBy) {
156
171
  const prior = await readJob(deps.harness, id, ctx);
@@ -191,7 +206,7 @@ export function createJobsService(deps) {
191
206
  const record = await readJob(deps.harness, id, ctx);
192
207
  if (record === undefined)
193
208
  throw new JobNotFoundError(id);
194
- return jobWireOf(id, record);
209
+ return wireOf(id, record);
195
210
  },
196
211
  async runs(id, limit) {
197
212
  return await listRuns(deps.storage, id, Math.max(0, limit), ctx);
@@ -205,14 +220,26 @@ export function createJobsService(deps) {
205
220
  throw new JobNotFoundError(id);
206
221
  if (prior.source !== "api")
207
222
  throw new JobDeclaredError(id);
223
+ // Resolve against the prior owner: the retiring commit removes the
224
+ // record, and state cleanup must not fall back to another user.
225
+ const root = stateRoot(prior.owner);
208
226
  const nowIso = new Date(deps.runtime.now()).toISOString();
209
227
  await deps.harness.commit(async (tx) => {
210
228
  // The final `enabled:false` revision, then the retire.
211
229
  await writeJobChange(tx, id, { ...prior, enabled: false }, changedBy, nowIso);
212
230
  await tx.retireDoc(JobDoc, id);
213
231
  }, ctx);
232
+ await removeStateBestEffort(root, id);
214
233
  await reconcileOne();
215
234
  },
235
+ async resetState(id) {
236
+ const record = await readJob(deps.harness, id, ctx);
237
+ if (record === undefined)
238
+ throw new JobNotFoundError(id);
239
+ // Unlike delete/transfer/reconcile, an explicit reset surfaces I/O
240
+ // failure to its caller; it never rewrites or retires the job.
241
+ await removeJobState(stateRoot(record.owner), id);
242
+ },
216
243
  async runNow(id) {
217
244
  // ONE clock reading: the manual fire key and the run's fireAt must
218
245
  // agree — the settle path re-derives the run-record key from
@@ -38,6 +38,7 @@
38
38
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
39
39
  import { JobDoc, patchJob, scanJobs, writeJobChange, } from "./docs.js";
40
40
  import { nextFireAt } from "./schedule.js";
41
+ import { jobStateDir, removeJobState } from "./state-dir.js";
41
42
  import { specHash } from "./spec.js";
42
43
  import { createScheduleTask } from "./task.js";
43
44
  const SCAN_PAGE = 500;
@@ -153,6 +154,14 @@ export async function reconcileJobs(deps, ctx = BACKGROUND_CONTEXT) {
153
154
  if (declaredIds.has(id))
154
155
  continue;
155
156
  await retireRecord(deps.harness, id, record, nowIso, ctx);
157
+ const root = deps.jobsRoot?.(record.owner);
158
+ try {
159
+ await removeJobState(root, id);
160
+ }
161
+ catch (error) {
162
+ const path = root === undefined ? id : jobStateDir(root, id);
163
+ console.warn(`jobs: could not remove retired job state ${path}: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
156
165
  }
157
166
  // ── Pass 2: records → tasks ─────────────────────────────────────
158
167
  const after = new Map();
@@ -0,0 +1,16 @@
1
+ /** Resolve and remove one job's on-disk state directory. */
2
+ import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /** The complete job id is encoded into one path segment. */
5
+ export function jobStateDir(root, jobId) {
6
+ return join(root, encodeURIComponent(jobId));
7
+ }
8
+ /**
9
+ * Remove job state, or do nothing when the selected owner has no jobs root.
10
+ * Deletion failures reject so each job operation can apply its own policy.
11
+ */
12
+ export async function removeJobState(root, jobId) {
13
+ if (root === undefined)
14
+ return;
15
+ await rm(jobStateDir(root, jobId), { recursive: true, force: true });
16
+ }
@@ -17,8 +17,10 @@
17
17
  * ownerless until the source is edited — spec §8.3's posture).
18
18
  */
19
19
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
20
+ import { join } from "node:path";
20
21
  import { registerOwnershipParticipant } from "../server/ownership.js";
21
22
  import { scanJobs, writeJobChange } from "./docs.js";
23
+ import { jobStateDir } from "./state-dir.js";
22
24
  import { specHash } from "./spec.js";
23
25
  /**
24
26
  * Register the jobs transfer participant on `ctx` (createServer calls
@@ -36,6 +38,18 @@ export function registerJobsOwnershipParticipant(ctx) {
36
38
  continue;
37
39
  if (record.owner !== from)
38
40
  continue;
41
+ try {
42
+ // Before the owner rewrite: resetState resolves the old owner's
43
+ // root. The recipient's folder is deliberately never created.
44
+ await jobs.resetState(id);
45
+ }
46
+ catch (error) {
47
+ // Directory-user api jobs use their managed user area. (OS-user
48
+ // jobs cannot enter this transfer path — user deletion refuses
49
+ // osUser workspaces before any participant runs.)
50
+ const root = join(ctx.managedPaths.userAreas(from), "jobs");
51
+ console.warn(`jobs: could not remove transferred job state ${jobStateDir(root, id)}: ${error instanceof Error ? error.message : String(error)}`);
52
+ }
39
53
  const spec = { ...record.spec, owner: to };
40
54
  const next = {
41
55
  ...record,
@@ -162,21 +162,22 @@ export function buildUserPolicy(record, ctx) {
162
162
  };
163
163
  }
164
164
  // EVERY per-user path passes the same validator before entering policy:
165
- // stored workspace/grants via revalidate, and workspace/home again below.
165
+ // stored workspace/grants via revalidate, and workspace/home/jobs again below.
166
166
  // An osAccount home must validate BEFORE any directory is created.
167
167
  // tmp is the ONE sanctioned stateDir exception: it is derived only from
168
168
  // stateDir/sandbox/<user>/tmp and never silently routed around validation.
169
169
  const userArea = ctx.managed.userAreas(record.user);
170
170
  const requestedWorkspace = record.workspace ?? ctx.osAccount?.home ?? join(userArea, "workspace");
171
171
  const requestedHome = ctx.osAccount?.home ?? join(userArea, "home");
172
+ const requestedJobs = join(userArea, "jobs");
172
173
  if (ctx.osAccount !== undefined) {
173
174
  const validation = validateUserPath(requestedHome, record, ctx.managed);
174
175
  if (!validation.ok)
175
176
  return invalidReason(requestedHome, validation.reason);
176
177
  }
177
178
  const tmp = join(ctx.managed.roots.stateDir, "sandbox", record.user, "tmp");
178
- for (const path of [requestedWorkspace, requestedHome, tmp]) {
179
- if (ctx.osAccount !== undefined && path === tmp)
179
+ for (const path of [requestedWorkspace, requestedHome, tmp, requestedJobs]) {
180
+ if (ctx.osAccount !== undefined && (path === tmp || path === requestedJobs))
180
181
  continue;
181
182
  mkdirSync(path, { recursive: true, mode: 0o700 });
182
183
  }
@@ -197,6 +198,12 @@ export function buildUserPolicy(record, ctx) {
197
198
  const homeValidation = validateUserPath(requestedHome, record, ctx.managed);
198
199
  if (!homeValidation.ok)
199
200
  return invalidReason(requestedHome, homeValidation.reason);
201
+ const jobsValidation = ctx.osAccount === undefined
202
+ ? validateUserPath(requestedJobs, record, ctx.managed)
203
+ : undefined;
204
+ if (jobsValidation !== undefined && !jobsValidation.ok) {
205
+ return invalidReason(requestedJobs, jobsValidation.reason);
206
+ }
200
207
  const realTmp = canonical(tmp) ?? (ctx.osAccount === undefined
201
208
  ? undefined
202
209
  : resolve(tmp));
@@ -204,6 +211,7 @@ export function buildUserPolicy(record, ctx) {
204
211
  return invalidReason(tmp, "path cannot be resolved");
205
212
  const realWorkspace = workspaceValidation.realpath;
206
213
  const realHome = homeValidation.realpath;
214
+ const realJobs = jobsValidation?.realpath;
207
215
  // A source checkout that has ever been built has BOTH src/ and dist/;
208
216
  // defaultModuleTrees picks one while the worker entry may use the other
209
217
  // (dist worker.js when present, src worker.ts via tsx otherwise).
@@ -227,9 +235,10 @@ export function buildUserPolicy(record, ctx) {
227
235
  ...grants.flatMap((grant) => (grant.access === "rw" ? [grant.path] : [])),
228
236
  realHome,
229
237
  realTmp,
238
+ ...(realJobs === undefined ? [] : [realJobs]),
230
239
  // Device creation belongs to rw, but the selected devices' ordinary
231
240
  // read/write I/O survives a read-only Landlock rule. rw stays empty
232
- // except for workspace/home/tmp/grants so /dev/shm remains denied.
241
+ // except for workspace/home/jobs/tmp/grants so /dev/shm remains denied.
233
242
  ]);
234
243
  const ro = sortedUnique([
235
244
  "/dev",
package/dist/serve.js CHANGED
@@ -175,9 +175,11 @@ export async function serve(env, options) {
175
175
  const envModel = env.SIGRUN_DEFAULT_MODEL?.trim() || env.SIGRUN_MODEL?.trim() || undefined;
176
176
  const settingsStore = new SettingsStore({ dir: defaultSettingsDir() });
177
177
  settingsStore.bootstrap(envServerSection(env), {
178
- extensions: env.SIGRUN_EXTENSIONS?.trim() === undefined
179
- ? undefined
180
- : envConfig.extensions,
178
+ // E8 smoke: SIGRUN_EXTENSIONS without any model env crashed boot —
179
+ // envConfig is only resolved when a model env is set (see envHasModel
180
+ // above), so envConfig!.extensions dereferenced undefined here.
181
+ // parseExtensionList is the env-side source of truth; use it directly.
182
+ extensions: parseExtensionList(env.SIGRUN_EXTENSIONS),
181
183
  defaultModel: envModel === undefined
182
184
  ? undefined
183
185
  : `${envConfig.models.provider}/${envConfig.models.modelId}`,
@@ -146,6 +146,17 @@ export async function createConversationOp(ctx, input = {}) {
146
146
  if (input.name !== undefined && typeof input.name !== "string") {
147
147
  throw new ConversationOpError({ error: "invalid_field", field: "name" });
148
148
  }
149
+ if (input.job !== undefined) {
150
+ // Resolve the live job (and therefore its EFFECTIVE owner) before the
151
+ // createConversation table write or the session-meta commit. An absent
152
+ // requested owner and an absent effective owner are an exact match.
153
+ const job = typeof input.job === "string"
154
+ ? await opened.jobs.get(input.job)
155
+ : undefined;
156
+ if (job === undefined || input.owner !== job.owner) {
157
+ throw new ConversationOpError({ error: "invalid_field", field: "job" });
158
+ }
159
+ }
149
160
  if (input.source !== undefined && typeof input.source !== "string") {
150
161
  throw new ConversationOpError({ error: "invalid_field", field: "source" });
151
162
  }
@@ -227,6 +238,8 @@ export async function createConversationOp(ctx, input = {}) {
227
238
  }
228
239
  if (input.name !== undefined)
229
240
  draft.name = input.name;
241
+ if (input.job !== undefined)
242
+ draft.jobId = input.job;
230
243
  if (input.source !== undefined)
231
244
  draft.source = input.source;
232
245
  await opened.rewriteUserSelection(tx, conversation.id, undefined, input.owner);
@@ -22,6 +22,7 @@ import { userWire } from "./routes/users.js";
22
22
  import { accessPrincipalOf } from "./auth/access-principal.js";
23
23
  import { abortOp, compactOp, configureOp, ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readFileOp, readMetaOp, submitOp, uploadOp, } from "./conversation-ops.js";
24
24
  import { listModels } from "./helpers.js";
25
+ import { notifyExtension } from "./notifications.js";
25
26
  import { SessionMetaDoc } from "./session-meta.js";
26
27
  import { accountLinkWire, serializeConversationView } from "./views.js";
27
28
  /**
@@ -108,6 +109,15 @@ export function createHostBridge(ctx) {
108
109
  await conversationOf(id);
109
110
  return await readMetaOp(opened, id);
110
111
  },
112
+ async entries(id, query, limit, cursor) {
113
+ const conversation = await conversationOf(id);
114
+ return await conversation.entries(query, limit, cursor, BACKGROUND_CONTEXT);
115
+ },
116
+ async entry(id, entryId) {
117
+ const conversation = await conversationOf(id);
118
+ const page = await conversation.entries({ minEntryId: entryId, maxEntryId: entryId }, 1, undefined, BACKGROUND_CONTEXT);
119
+ return page.items[0];
120
+ },
111
121
  // Gateway C4a: the remaining conversation ops, each the SAME op
112
122
  // its HTTP route runs — resolved through conversationOf (unknown id
113
123
  // → the bridge's loud `conversation <id> not found`).
@@ -170,6 +180,20 @@ export function createHostBridge(ctx) {
170
180
  return await readFileOp(ctx, conversation, path);
171
181
  },
172
182
  },
183
+ jobs: {
184
+ async get(id) {
185
+ const job = await opened.jobs.get(id);
186
+ if (job === undefined)
187
+ return undefined;
188
+ return {
189
+ id: job.id,
190
+ name: job.name,
191
+ ...(job.owner === undefined ? {} : { owner: job.owner }),
192
+ enabled: job.enabled,
193
+ state: job.state,
194
+ };
195
+ },
196
+ },
173
197
  users: {
174
198
  // The directory in wire form (emails are admin material; the
175
199
  // bridge is in-process server code, the same trust as the
@@ -279,6 +303,7 @@ export function createHostBridge(ctx) {
279
303
  : { notice: result.notice };
280
304
  },
281
305
  },
306
+ notify: (slug, input) => notifyExtension(ctx, slug, input),
282
307
  surfaces: {
283
308
  // UI-blocks U3 Task 4 (spec §7.3): the same bumpRev the actions
284
309
  // route takes — the rev advance and the hub event are one step.
@@ -40,6 +40,29 @@ import { SessionMetaDoc } from "./session-meta.js";
40
40
  function displayName(meta, id) {
41
41
  return meta?.name ?? meta?.autoName ?? `Conversation ${id}`;
42
42
  }
43
+ /** Collect every loaded notifier with its contributing extension's host. */
44
+ export function collectTargets(ctx) {
45
+ const targets = [];
46
+ for (const binding of ctx.opened.notifierBindings) {
47
+ // The Task 2 ext-route host semantics: the captured seam host,
48
+ // cast across the not-yet-bridged members.
49
+ const host = ctx.opened.seamHosts.get(binding.extension);
50
+ for (const notifier of binding.notifiers) {
51
+ targets.push({ extension: binding.extension, notifier, host });
52
+ }
53
+ }
54
+ return targets;
55
+ }
56
+ /** Enqueue one per-recipient event through every notifier (never awaited). */
57
+ export function deliverEvent(ctx, targets, event) {
58
+ for (const target of targets) {
59
+ // Fire-and-forget per transport call: one notifier's failure or
60
+ // hang neither blocks nor loses the others' deliveries.
61
+ void ctx.opened.eventBus.runBounded(`notifier ${target.extension}.${target.notifier.name}`, () => target.notifier.notify(event, {
62
+ host: target.host,
63
+ }));
64
+ }
65
+ }
43
66
  /**
44
67
  * Start the notifications surface. Returns its unsubscribe (the server
45
68
  * never calls it today — the surface rides the process lifetime — but
@@ -48,15 +71,7 @@ function displayName(meta, id) {
48
71
  */
49
72
  export function startNotifications(ctx) {
50
73
  const { opened, config } = ctx;
51
- const targets = [];
52
- for (const binding of opened.notifierBindings) {
53
- // The Task 2 ext-route host semantics: the captured seam host,
54
- // cast across the not-yet-bridged members.
55
- const host = opened.seamHosts.get(binding.extension);
56
- for (const notifier of binding.notifiers) {
57
- targets.push({ extension: binding.extension, notifier, host });
58
- }
59
- }
74
+ const targets = collectTargets(ctx);
60
75
  if (targets.length === 0)
61
76
  return () => { };
62
77
  const bus = opened.eventBus;
@@ -83,13 +98,7 @@ export function startNotifications(ctx) {
83
98
  // the conditional spread keeps the wire shape exact).
84
99
  ...(base.outcome === undefined ? {} : { outcome: base.outcome }),
85
100
  };
86
- for (const target of targets) {
87
- // Fire-and-forget per transport call: one notifier's failure or
88
- // hang neither blocks nor loses the others' deliveries.
89
- void bus.runBounded(`notifier ${target.extension}.${target.notifier.name}`, () => target.notifier.notify(event, {
90
- host: target.host,
91
- }));
92
- }
101
+ deliverEvent(ctx, targets, event);
93
102
  }
94
103
  };
95
104
  /** One conversation's live SessionMeta (undefined on absence or read failure, logged). */
@@ -210,3 +219,80 @@ export function startNotifications(ctx) {
210
219
  });
211
220
  return unsubscribe;
212
221
  }
222
+ /**
223
+ * Validate and enqueue one extension notice. The caller's promise resolves
224
+ * after validation and enqueueing, not after any transport has delivered.
225
+ */
226
+ export async function notifyExtension(ctx, slug, input) {
227
+ const users = ctx.opened.identity.users;
228
+ if (users.isClaimed()) {
229
+ if (input.user === undefined) {
230
+ throw new Error("notification user is required when the directory is non-empty");
231
+ }
232
+ if (users.get(input.user) === undefined) {
233
+ throw new Error(`unknown user "${input.user}"`);
234
+ }
235
+ }
236
+ if (typeof input.title !== "string" ||
237
+ input.title.length < 1 ||
238
+ input.title.length > 200) {
239
+ throw new Error("notification title must be 1..200 characters");
240
+ }
241
+ if (typeof input.body !== "string" ||
242
+ input.body.length > 2000) {
243
+ throw new Error("notification body must be 0..2000 characters");
244
+ }
245
+ if (!/^[a-z0-9.-]{1,64}$/.test(input.kind)) {
246
+ throw new Error("notification kind must be 1..64 characters from [a-z0-9.-]");
247
+ }
248
+ if (input.target !== undefined) {
249
+ if (typeof input.target !== "object" || input.target === null || Array.isArray(input.target)) {
250
+ throw new Error("notification target must be an object");
251
+ }
252
+ const hasView = "view" in input.target;
253
+ const hasConversation = "conversation" in input.target;
254
+ if (hasView === hasConversation) {
255
+ throw new Error("notification target must have exactly one of view or conversation");
256
+ }
257
+ if ((hasView && typeof input.target.view !== "string") ||
258
+ (hasConversation && typeof input.target.conversation !== "number")) {
259
+ throw new Error("notification target requires a string view or a number conversation");
260
+ }
261
+ }
262
+ let conversationId;
263
+ let url;
264
+ if (input.target !== undefined && "view" in input.target) {
265
+ const view = input.target.view;
266
+ const binding = ctx.opened.extensionSurfaces.find((candidate) => candidate.extension === slug);
267
+ const declared = binding?.defs.some((def) => def.placement === "view" && def.id === view);
268
+ if (declared !== true) {
269
+ throw new Error(`notification view "${view}" was not declared by extension "${slug}"`);
270
+ }
271
+ if (ctx.config.publicUrl !== undefined) {
272
+ const base = `${ctx.config.publicUrl}/#/ext/${encodeURIComponent(slug)}/` +
273
+ encodeURIComponent(view);
274
+ const params = input.target.params ?? {};
275
+ url = Object.keys(params).length === 0
276
+ ? base
277
+ : `${base}?params=${encodeURIComponent(JSON.stringify(params))}`;
278
+ }
279
+ }
280
+ else {
281
+ conversationId = input.target?.conversation;
282
+ if (conversationId !== undefined && ctx.config.publicUrl !== undefined) {
283
+ url = `${ctx.config.publicUrl}/#/c/${conversationId}`;
284
+ }
285
+ }
286
+ const event = {
287
+ type: "extension",
288
+ extension: slug,
289
+ kind: input.kind,
290
+ conversationId,
291
+ jobId: undefined,
292
+ user: input.user,
293
+ title: input.title,
294
+ body: input.body,
295
+ ...(url === undefined ? {} : { url }),
296
+ };
297
+ deliverEvent(ctx, collectTargets(ctx), event);
298
+ }
@@ -212,6 +212,37 @@ export function registerJobRoutes(app, ctx) {
212
212
  return await domainError(reply, error);
213
213
  }
214
214
  });
215
+ // POST /v1/jobs/:id/state/reset — write access; any live job source.
216
+ // The job's definition and history stay intact; only its persistent
217
+ // state folder is removed. Unlike delete/transfer/reconcile cleanup,
218
+ // an explicit reset surfaces a failed removal to the caller.
219
+ app.post("/v1/jobs/:id/state/reset", async (request, reply) => {
220
+ const { id } = request.params;
221
+ const wire = await jobs.get(id);
222
+ if (wire === undefined) {
223
+ return await deny(reply, "job_not_found");
224
+ }
225
+ const access = accessOf(accessPrincipal(request), resourceOf(wire));
226
+ if (access === "none") {
227
+ return await deny(reply, "job_not_found");
228
+ }
229
+ if (access === "read") {
230
+ return await deny(reply, "forbidden");
231
+ }
232
+ try {
233
+ await jobs.resetState(id);
234
+ return await reply.code(204).send();
235
+ }
236
+ catch (error) {
237
+ if (error instanceof JobNotFoundError) {
238
+ return await deny(reply, "job_not_found");
239
+ }
240
+ return await reply.code(500).send({
241
+ error: "state_reset_failed",
242
+ message: error instanceof Error ? error.message : String(error),
243
+ });
244
+ }
245
+ });
215
246
  // POST /v1/jobs/:id/run — write access; fires now (overlap honored).
216
247
  // A LIVE record is required (like PATCH/DELETE): a retired job's
217
248
  // revision never satisfies run-now — 404, never fireJob's blocked-200
@@ -496,6 +496,15 @@ export const CORE_SETTINGS_SECTIONS = [
496
496
  help: "The built-in tool-search extension — applies at restart",
497
497
  apply: applyOf("builtinExtensions.toolSearch"),
498
498
  },
499
+ {
500
+ type: "toggle",
501
+ // Absent means ON (the built-in set defaults enabled).
502
+ default: true,
503
+ name: "builtinExtensions.gateway",
504
+ label: "Gateway",
505
+ help: "The built-in channel gateway engine — applies at restart",
506
+ apply: applyOf("builtinExtensions.gateway"),
507
+ },
499
508
  {
500
509
  type: "toggle",
501
510
  default: true,
@@ -169,6 +169,12 @@ export const CORE_SETTING_KEYS = [
169
169
  section: "extensions",
170
170
  reason: "the built-in set resolves at boot (a disabled built-in never loads)",
171
171
  },
172
+ {
173
+ key: "builtinExtensions.gateway",
174
+ apply: "restart",
175
+ section: "extensions",
176
+ reason: "the built-in set resolves at boot (a disabled built-in never loads)",
177
+ },
172
178
  {
173
179
  key: "builtinExtensions.oauth",
174
180
  apply: "restart",
@@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-11-02
9
+
10
+ Version-companion release for sigrun 0.6.0 (no shared changes).
8
11
  ## [0.5.0] - 2026-11-02
9
12
 
10
13
  Version-companion release for sigrun 0.5.0 (no shared changes).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/shared",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "sigrun shared leaf package \u2014 theme tokens, turn/conversation shaping, and the channel wire types (pure leaf, zero dependencies)",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/sigrun",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Self-hosted, multi-user personal-assistant server",
5
5
  "type": "module",
6
6
  "exports": {
@@ -46,11 +46,11 @@
46
46
  "@fastify/static": "^10.1.7",
47
47
  "@sigrun/ext-codemode": "0.1.2",
48
48
  "@sigrun/ext-curator": "0.1.0",
49
- "@sigrun/ext-gateway": "0.1.9",
49
+ "@sigrun/ext-gateway": "0.1.10",
50
50
  "@sigrun/ext-mcp": "0.2.2",
51
51
  "@sigrun/ext-tool-search": "0.1.2",
52
52
  "@sigrun/oauth": "0.1.1",
53
- "@sigrun/shared": "0.5.0",
53
+ "@sigrun/shared": "0.6.0",
54
54
  "croner": "^10.0.1",
55
55
  "fastify": "^5.12.7",
56
56
  "semver": "^7.8.5",
@@ -61,9 +61,9 @@
61
61
  "@sigrun/shared"
62
62
  ],
63
63
  "optionalDependencies": {
64
- "@sigrun/sandbox-linux-arm64": "0.5.0",
65
- "@sigrun/sandbox-linux-x64": "0.5.0",
66
- "@sigrun/tui": "0.5.0"
64
+ "@sigrun/sandbox-linux-arm64": "0.6.0",
65
+ "@sigrun/sandbox-linux-x64": "0.6.0",
66
+ "@sigrun/tui": "0.6.0"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@sigrun/memory-markdown": "file:../sigrun-memory-markdown",