@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.
- package/CHANGELOG.md +55 -0
- package/README.md +18 -11
- package/dist/extension.d.ts +54 -1
- package/dist/harness/boot.js +27 -4
- package/dist/harness/builtins.js +9 -5
- package/dist/harness/identity/primary-admin.js +19 -0
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +15 -0
- package/dist/harness/sections/context-files.js +6 -3
- package/dist/harness/sections/job-state.js +24 -0
- package/dist/harness/workspace.js +30 -3
- package/dist/jobs/fire.js +4 -3
- package/dist/jobs/index.js +34 -7
- package/dist/jobs/reconcile.js +9 -0
- package/dist/jobs/state-dir.js +16 -0
- package/dist/jobs/transfer.js +14 -0
- package/dist/sandbox/policy.js +13 -4
- package/dist/serve.js +5 -3
- package/dist/server/conversation-ops.js +13 -0
- package/dist/server/host-bridge.js +25 -0
- package/dist/server/notifications.js +102 -16
- package/dist/server/routes/jobs.js +31 -0
- package/dist/settings/core-sections.js +9 -0
- package/dist/settings/registry.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +3 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +6 -6
- package/webui/dist/assets/index-BR0di0OG.css +1 -0
- package/webui/dist/assets/{index-BtczbUXo.js → index-BzrA1T4g.js} +5 -5
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-h7zDY9vD.css +0 -1
package/dist/jobs/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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 }) =>
|
|
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 :
|
|
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
|
|
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
|
|
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
|
package/dist/jobs/reconcile.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/jobs/transfer.js
CHANGED
|
@@ -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,
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sigrun/sigrun",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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.
|
|
65
|
-
"@sigrun/sandbox-linux-x64": "0.
|
|
66
|
-
"@sigrun/tui": "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",
|