@sigrun/sigrun 0.2.0 → 0.3.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 +145 -7
- package/README.md +333 -13
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +48 -8
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +92 -6
- package/dist/harness/builtins.js +1 -1
- package/dist/harness/context.js +25 -0
- package/dist/harness/event-bus.js +25 -14
- package/dist/harness/identity/accounts.js +3 -2
- package/dist/harness/identity/index.js +2 -1
- package/dist/harness/identity/invites.js +26 -9
- package/dist/harness/identity/link-sweep.js +47 -0
- package/dist/harness/identity/typed-username.js +15 -0
- package/dist/harness/identity/users.js +10 -7
- package/dist/harness/interactions.js +2 -0
- package/dist/harness/loader.js +57 -1
- package/dist/harness/migration-snapshot.js +97 -0
- package/dist/harness/reports.js +100 -0
- package/dist/harness/seam-host.js +23 -1
- package/dist/harness/sections/context-files.js +1 -1
- package/dist/harness/settings.js +52 -4
- package/dist/jobs/docs.js +3 -0
- package/dist/jobs/fire.js +2 -0
- package/dist/jobs/parked.js +38 -0
- package/dist/jobs/task.js +37 -8
- package/dist/sandbox/availability.js +69 -20
- package/dist/sandbox/launch.js +11 -0
- package/dist/sandbox/policy.js +24 -3
- package/dist/sandbox/worker.js +4 -24
- package/dist/serve.js +162 -40
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +93 -28
- package/dist/server/auth/proxy-diagnostic.js +60 -0
- package/dist/server/auth/proxy-identity.js +134 -0
- package/dist/server/auth/proxy-only.js +28 -0
- package/dist/server/images/cache.js +13 -5
- package/dist/server/index.js +10 -2
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/notifications.js +21 -4
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/images.js +16 -9
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/me.js +1 -1
- package/dist/server/routes/meta.js +4 -0
- package/dist/server/routes/metrics.js +18 -2
- package/dist/server/routes/onboarding.js +97 -37
- package/dist/server/session-meta.js +1 -0
- package/dist/server/shutdown.js +93 -16
- package/dist/settings/registry.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
- package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +33 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +16 -15
- package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/CONTEXT.md +0 -163
- package/dist/server/login/usernames.js +0 -15
package/dist/extension.d.ts
CHANGED
|
@@ -139,7 +139,13 @@ export type ExtensionLifecyclePayload = {
|
|
|
139
139
|
export type ExtensionRunSettledHandler = (event: RunSettledEvent, api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
|
|
140
140
|
/** A factory-result lifecycle handler for one event, deadline-bounded. */
|
|
141
141
|
export type ExtensionLifecycleHandler<K extends keyof ExtensionLifecyclePayload> = (event: ExtensionLifecyclePayload[K], api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
|
|
142
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* A factory-result `harnessShutdown` handler (no event payload — shutdown is
|
|
144
|
+
* conversation-less). All extension handlers run CONCURRENTLY under one
|
|
145
|
+
* shared hook deadline, in no guaranteed relative execution or commit order.
|
|
146
|
+
* Handlers may still commit; a wedged handler is abandoned without undoing
|
|
147
|
+
* other handlers' durable commits.
|
|
148
|
+
*/
|
|
143
149
|
export type ExtensionHarnessShutdownHandler = (api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
|
|
144
150
|
/**
|
|
145
151
|
* The `userDeleted` payload (core hardening W1): which directory user
|
|
@@ -349,14 +355,18 @@ export interface ExtensionCommandContribution {
|
|
|
349
355
|
readonly notice?: string;
|
|
350
356
|
} | void>;
|
|
351
357
|
}
|
|
352
|
-
/**
|
|
353
|
-
|
|
354
|
-
|
|
358
|
+
/** The settled-run outcome: the notification-safe projection of the host
|
|
359
|
+
* event's `outcome`/`reason` (status and reason only — no answer text). */
|
|
360
|
+
export type RunSettledOutcome = {
|
|
361
|
+
readonly status: "answered";
|
|
362
|
+
} | {
|
|
363
|
+
readonly status: "unanswered";
|
|
364
|
+
readonly reason: string;
|
|
365
|
+
};
|
|
366
|
+
/** Fields shared by every notification variant. */
|
|
367
|
+
interface NotificationEventBase {
|
|
355
368
|
readonly conversationId?: number;
|
|
356
369
|
readonly jobId?: string;
|
|
357
|
-
/** Gateway C6: the pending record's id — set on `interaction.pending` only
|
|
358
|
-
* (read the record via `host.snapshotInteraction`). */
|
|
359
|
-
readonly callId?: string;
|
|
360
370
|
/** The recipient. */
|
|
361
371
|
readonly user: string | undefined;
|
|
362
372
|
readonly title: string;
|
|
@@ -364,6 +374,21 @@ export interface NotificationEvent {
|
|
|
364
374
|
/** `${server.publicUrl}/#/c/<conversationId>` when `server.publicUrl` is set; absent otherwise. */
|
|
365
375
|
readonly url?: string;
|
|
366
376
|
}
|
|
377
|
+
/** One notification core delivers to every registered notifier (best-effort). */
|
|
378
|
+
export type NotificationEvent = NotificationEventBase & ({
|
|
379
|
+
readonly type: "interaction.pending";
|
|
380
|
+
/** Gateway C6: the pending record's id — set on `interaction.pending` only
|
|
381
|
+
* (inspect it via `host.peekInteraction`; the OWNING extension's
|
|
382
|
+
* delivery read is `host.snapshotInteraction`). */
|
|
383
|
+
readonly callId?: string;
|
|
384
|
+
} | {
|
|
385
|
+
readonly type: "job.failed";
|
|
386
|
+
} | {
|
|
387
|
+
readonly type: "run.settled";
|
|
388
|
+
/** Whether the run answered or not (model error, abort, …), with
|
|
389
|
+
* the wire reason verbatim. Never infer success from the body. */
|
|
390
|
+
readonly outcome: RunSettledOutcome;
|
|
391
|
+
});
|
|
367
392
|
/** One notifier transport (ntfy, Signal, web push — core ships none). */
|
|
368
393
|
export interface NotifierContribution {
|
|
369
394
|
readonly name: string;
|
|
@@ -800,7 +825,22 @@ export interface SigrunHost {
|
|
|
800
825
|
}): Promise<boolean>;
|
|
801
826
|
/** Mark a pending interaction expired/cancelled/skipped (extension-owned semantics). */
|
|
802
827
|
settleInteraction(conversationId: ConversationId, callId: string, status: "skipped" | "expired" | "cancelled", context: HandlerContext): Promise<boolean>;
|
|
803
|
-
/**
|
|
828
|
+
/**
|
|
829
|
+
* Inspect one interaction record WITHOUT consuming anything: returns
|
|
830
|
+
* the same stored projection as {@link SigrunHost.snapshotInteraction}
|
|
831
|
+
* (secret values stay `SECRET_MASK`), but never touches the one-shot
|
|
832
|
+
* secret-delivery `take` path. Safe to repeat; neither extends nor
|
|
833
|
+
* shortens secret TTL; creates, settles, and mutates nothing. Use for
|
|
834
|
+
* inspection/rendering (notifiers, outcome edits, correlation).
|
|
835
|
+
*/
|
|
836
|
+
peekInteraction(conversationId: ConversationId, callId: string, context: HandlerContext): Promise<Readonly<InteractionRecord> | undefined>;
|
|
837
|
+
/**
|
|
838
|
+
* The OWNING extension's delivery read: the first applicable read of
|
|
839
|
+
* an answered secret record merges its raw secret values back ONCE;
|
|
840
|
+
* subsequent reads see the stored mask. Inspection-only callers must
|
|
841
|
+
* use {@link SigrunHost.peekInteraction} instead — a consuming read
|
|
842
|
+
* by a non-owner silently destroys the owner's one-shot delivery.
|
|
843
|
+
*/
|
|
804
844
|
snapshotInteraction(conversationId: ConversationId, callId: string, context: HandlerContext): Promise<Readonly<InteractionRecord> | undefined>;
|
|
805
845
|
/** Fully replace one ambient surface's state (write = replacement); the key must be unowned or yours. */
|
|
806
846
|
setAmbient(conversationId: ConversationId, record: {
|
package/dist/harness/ambient.js
CHANGED
|
@@ -42,6 +42,7 @@ export const AmbientDoc = defineDocFamily({
|
|
|
42
42
|
kind: "sigrun.ambient",
|
|
43
43
|
version: 1,
|
|
44
44
|
scope: "conversation",
|
|
45
|
+
checkpointWhen: () => false,
|
|
45
46
|
history: "latest",
|
|
46
47
|
fork: "current",
|
|
47
48
|
family: true,
|
|
@@ -57,6 +58,7 @@ export const AmbientOwnersDoc = defineDoc({
|
|
|
57
58
|
kind: "sigrun.ambient.owners",
|
|
58
59
|
version: 1,
|
|
59
60
|
scope: "session",
|
|
61
|
+
checkpointWhen: () => false,
|
|
60
62
|
initial: () => ({ owners: {} }),
|
|
61
63
|
});
|
|
62
64
|
const PLACES = new Set(["status", "footer", "panel"]);
|
package/dist/harness/boot.js
CHANGED
|
@@ -12,10 +12,14 @@ import { join } from "node:path";
|
|
|
12
12
|
import { Harness, } from "@earendil-works/pi-durable";
|
|
13
13
|
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
|
|
14
14
|
import { effectiveMethods } from "../config.js";
|
|
15
|
+
import { requiresProxyVouch } from "../server/auth/proxy-only.js";
|
|
15
16
|
import { wireActivity } from "./activity.js";
|
|
17
|
+
import { snapshotBeforeMigration } from "./migration-snapshot.js";
|
|
16
18
|
import { createScheduleTask } from "../jobs/task.js";
|
|
17
19
|
import { createJobsService, jobWireOf } from "../jobs/index.js";
|
|
18
20
|
import { reconcileJobs } from "../jobs/reconcile.js";
|
|
21
|
+
import { applyParkedJobs } from "../jobs/parked.js";
|
|
22
|
+
import { createDurableReports } from "./reports.js";
|
|
19
23
|
import { registerJobObservers } from "../jobs/observe.js";
|
|
20
24
|
import { JobDoc, JobRunDoc, listRevisions } from "../jobs/docs.js";
|
|
21
25
|
import { EventBus } from "./event-bus.js";
|
|
@@ -49,6 +53,7 @@ import { resolveWorkerEntry, sandboxLauncher, WorkerPool } from "../sandbox/pool
|
|
|
49
53
|
import { IsolationState } from "../sandbox/isolation.js";
|
|
50
54
|
import { probeOsUsers } from "../sandbox/availability.js";
|
|
51
55
|
import { createAdmissionGuard } from "./identity/users.js";
|
|
56
|
+
import { sweepAccountLinks } from "./identity/link-sweep.js";
|
|
52
57
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
53
58
|
import { probeIsolation, runHelperProbe, selfTestHelper, } from "../sandbox/availability.js";
|
|
54
59
|
import { resolveBuiltins, } from "./builtins.js";
|
|
@@ -169,6 +174,31 @@ export async function openHarness(config, options = {}) {
|
|
|
169
174
|
// I/O here (each file materializes on its first write), so this is
|
|
170
175
|
// pure construction over paths boot already owns.
|
|
171
176
|
const identity = options.identity ?? openIdentityStores(roots);
|
|
177
|
+
// The account-link sweep (username-derivation Task 8): once at boot,
|
|
178
|
+
// before serving — dangling links are removed, proxy-source crash
|
|
179
|
+
// orphans are logged with their repair command (never auto-repaired:
|
|
180
|
+
// the original proxy username is not derivable from the key).
|
|
181
|
+
// BEST-EFFORT posture: a sweep failure (e.g. a corrupt accounts.json)
|
|
182
|
+
// must not crash-loop a serving deployment; the stores' own readers
|
|
183
|
+
// still fail loud on the request paths if the file is truly corrupt.
|
|
184
|
+
try {
|
|
185
|
+
const linkSweep = await sweepAccountLinks(identity.users, identity.accounts);
|
|
186
|
+
if (linkSweep.removedDangling > 0) {
|
|
187
|
+
console.log(`identity: removed ${linkSweep.removedDangling} dangling account link(s)`);
|
|
188
|
+
}
|
|
189
|
+
for (const orphan of linkSweep.orphans) {
|
|
190
|
+
console.log(`identity: proxy user "${orphan}" has no proxy link; repair with: sigrun user link ${orphan} --proxy <username>`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
console.warn(`identity: account-link sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
196
|
+
// The caller-supplied host wins (serving spec §1: `serve()` resolves
|
|
197
|
+
// the ONE effective host — options.host → SIGRUN_LISTEN_HOST →
|
|
198
|
+
// DEFAULT_LISTEN_HOST — and passes it here). The env read below is
|
|
199
|
+
// NOT a second source on the serve path (the option is always set
|
|
200
|
+
// there); it remains for direct `openHarness` callers that never
|
|
201
|
+
// bind a listener (tests) and must derive the same auth posture.
|
|
172
202
|
const listenHost = options.listenHost ?? env.SIGRUN_LISTEN_HOST ?? "0.0.0.0";
|
|
173
203
|
const methods = effectiveMethods(config.auth, listenHost);
|
|
174
204
|
const isolationState = new IsolationState({
|
|
@@ -203,8 +233,10 @@ export async function openHarness(config, options = {}) {
|
|
|
203
233
|
// is 0600 + atomic, and the cache root is never a backup section).
|
|
204
234
|
// `SIGRUN_SETUP_CODE` pins the value instead (the k8s Secret shape).
|
|
205
235
|
// Claimed boots (every identity-seeded test, every post-onboarding
|
|
206
|
-
// deploy) write nothing.
|
|
207
|
-
|
|
236
|
+
// deploy) write nothing. A proxy-only non-loopback boot writes and
|
|
237
|
+
// logs NOTHING either (C2): account creation there is vouch-only, so
|
|
238
|
+
// a setup code would be an unvouched account-creation path.
|
|
239
|
+
if (!identity.users.isClaimed() && !requiresProxyVouch(methods, listenHost)) {
|
|
208
240
|
// `identity.cacheDir`, not `roots.cacheDir`: the claim route reads
|
|
209
241
|
// `readSetupCode(identity.cacheDir)`, and an injected identity
|
|
210
242
|
// bundle (the test seam) may root elsewhere than the resolved
|
|
@@ -263,7 +295,18 @@ export async function openHarness(config, options = {}) {
|
|
|
263
295
|
now: () => Date.now(),
|
|
264
296
|
managed: managedPaths,
|
|
265
297
|
});
|
|
266
|
-
const storage = await
|
|
298
|
+
const storage = await (async () => {
|
|
299
|
+
// D4: preserve the last schema the previous release can read BEFORE
|
|
300
|
+
// openNodeSqliteStorage applies its one-way durable migration. The
|
|
301
|
+
// injected clock keeps snapshots deterministic in tests; a needed
|
|
302
|
+
// snapshot failure aborts boot here, before any migration write.
|
|
303
|
+
await snapshotBeforeMigration(config.storagePath, {
|
|
304
|
+
env,
|
|
305
|
+
now: options.now ?? (() => new Date()),
|
|
306
|
+
log: (line) => console.log(line),
|
|
307
|
+
});
|
|
308
|
+
return await openNodeSqliteStorage(config.storagePath);
|
|
309
|
+
})();
|
|
267
310
|
const registry = createRegistry();
|
|
268
311
|
// Late-filled by the loader: factories publish through the same registry
|
|
269
312
|
// instance, and selection rewrites read the declared set LIVE.
|
|
@@ -654,6 +697,25 @@ export async function openHarness(config, options = {}) {
|
|
|
654
697
|
onReadinessChange: observeReadiness,
|
|
655
698
|
snapshotReadiness,
|
|
656
699
|
});
|
|
700
|
+
// Per-model compaction overrides (D9): after the boot refresh the
|
|
701
|
+
// catalog is as complete as boot gets, so an override key matching no
|
|
702
|
+
// known model is almost always a typo (or a stale chain name). Warn
|
|
703
|
+
// once per unmatched key — never an error: catalogs move and an
|
|
704
|
+
// unmatched override is simply inert. pi-durable's own key composition
|
|
705
|
+
// is `${provider}/${modelId}`, so a chain-shaped model id yields a
|
|
706
|
+
// doubled prefix (e.g. "bifrost/bifrost/<chain>").
|
|
707
|
+
void bootRefreshDone.then(() => {
|
|
708
|
+
const overrides = config.compaction.modelOverrides ??
|
|
709
|
+
bootSettings.server?.compaction?.modelOverrides;
|
|
710
|
+
if (overrides === undefined)
|
|
711
|
+
return;
|
|
712
|
+
const known = new Set(models.getModels().map((model) => `${model.provider}/${model.id}`));
|
|
713
|
+
for (const key of Object.keys(overrides)) {
|
|
714
|
+
if (!known.has(key)) {
|
|
715
|
+
console.warn(`compaction override "${key}" matches no known model`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
});
|
|
657
719
|
// The boot-wired single-provider refresh (W3 Task 6): the route
|
|
658
720
|
// surface's refresh — the same bound policy `resolveServedModelRefreshing`
|
|
659
721
|
// runs on a miss, with the same readiness observation so a provider a
|
|
@@ -718,15 +780,23 @@ export async function openHarness(config, options = {}) {
|
|
|
718
780
|
const fileSettings = settingsStore.harnessSettings();
|
|
719
781
|
const callerSettings = options.settings;
|
|
720
782
|
// The ONE boot clock (options.now ?? real): the activity stamper AND
|
|
721
|
-
// the harness clock share it (the schedule task's `
|
|
722
|
-
//
|
|
723
|
-
// tests' time-injection lever).
|
|
783
|
+
// the harness clock share it (the schedule task's `sleepUntil`
|
|
784
|
+
// deadlines live on the harness clock; pinning the seam pins both —
|
|
785
|
+
// the jobs tests' time-injection lever).
|
|
724
786
|
const clockNow = options.now ?? (() => new Date());
|
|
787
|
+
// D5: reports may arrive inside Harness.open. The collector logs/counts
|
|
788
|
+
// synchronously and queues parks; job patches drain after binding below.
|
|
789
|
+
const reports = createDurableReports({
|
|
790
|
+
log: (line) => console.error(line),
|
|
791
|
+
onReport: options.onReport,
|
|
792
|
+
});
|
|
725
793
|
const harness = await Harness.open(storage, {
|
|
726
794
|
models,
|
|
727
795
|
registry,
|
|
728
796
|
env: envBuilder,
|
|
729
797
|
now: () => clockNow().getTime(),
|
|
798
|
+
setTimer: options.setTimer,
|
|
799
|
+
onReport: reports.onReport,
|
|
730
800
|
// Context-management policy (Task 8): config's `SIGRUN_COMPACTION_*`
|
|
731
801
|
// tuning, with any caller-set field winning over it. Upstream merges
|
|
732
802
|
// the result over `DEFAULT_COMPACTION_POLICY` per field. Task 8a: the
|
|
@@ -738,6 +808,14 @@ export async function openHarness(config, options = {}) {
|
|
|
738
808
|
// snapshots the file's boot-time values, silently freezing the
|
|
739
809
|
// queue modes and clobbering the caller seam (the round-1 defects).
|
|
740
810
|
// `extensions`/`toolExecution` have no file backing — plain passthrough.
|
|
811
|
+
// Compaction (D9): ONLY `modelOverrides` comes from the file here —
|
|
812
|
+
// it has no env spelling, so this adds a file tier without touching
|
|
813
|
+
// the four env-backed knobs' precedence (env over file, resolved by
|
|
814
|
+
// loadConfig). serve already folds the file into config; the
|
|
815
|
+
// conditional spread keeps a config-resolved map authoritative and
|
|
816
|
+
// covers direct `openHarness` boots. Read ONCE (boot-time), so a
|
|
817
|
+
// PATCH is restart-pending exactly like the other compaction keys.
|
|
818
|
+
// Order: caller seam > config (env over file) > this file fallback.
|
|
741
819
|
settings: {
|
|
742
820
|
get steeringMode() {
|
|
743
821
|
return callerSettings?.steeringMode ?? fileSettings.steeringMode;
|
|
@@ -761,6 +839,10 @@ export async function openHarness(config, options = {}) {
|
|
|
761
839
|
toolExecution: callerSettings?.toolExecution,
|
|
762
840
|
compaction: {
|
|
763
841
|
...config.compaction,
|
|
842
|
+
...(config.compaction.modelOverrides === undefined &&
|
|
843
|
+
bootSettings.server?.compaction?.modelOverrides !== undefined
|
|
844
|
+
? { modelOverrides: bootSettings.server.compaction.modelOverrides }
|
|
845
|
+
: {}),
|
|
764
846
|
...callerSettings?.compaction,
|
|
765
847
|
},
|
|
766
848
|
},
|
|
@@ -780,6 +862,9 @@ export async function openHarness(config, options = {}) {
|
|
|
780
862
|
// JobsService — happens after this point.
|
|
781
863
|
jobRuntimeDeps.harness = harness;
|
|
782
864
|
jobRuntimeDeps.storage = storage;
|
|
865
|
+
// Drain the restart-limit parks reported during open before scheduling
|
|
866
|
+
// resumes. A failed status patch is logged and dropped by applyParkedJobs.
|
|
867
|
+
await applyParkedJobs(harness, reports.takeParked(), BACKGROUND_CONTEXT);
|
|
783
868
|
// The memory-seam event bus (Tasks 2-4): subscribe BEFORE `resume()` so
|
|
784
869
|
// recovery settles (faulted/orphaned submissions the scheduler drains)
|
|
785
870
|
// are observed like any other commit. Extension handlers register under
|
|
@@ -1057,6 +1142,7 @@ export async function openHarness(config, options = {}) {
|
|
|
1057
1142
|
userToolSweep,
|
|
1058
1143
|
models,
|
|
1059
1144
|
jobs,
|
|
1145
|
+
reports,
|
|
1060
1146
|
jobRunTotals: () => jobRunTotals,
|
|
1061
1147
|
defaultModel,
|
|
1062
1148
|
providerCollisions: sink.collisions,
|
package/dist/harness/builtins.js
CHANGED
|
@@ -38,7 +38,7 @@ import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
|
38
38
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
39
39
|
import { parsePackageSpecifier, readPackageName } from "./packages.js";
|
|
40
40
|
/**
|
|
41
|
-
* The
|
|
41
|
+
* The five built-ins, in load order (codemode before tool-search: both
|
|
42
42
|
* wrap pi-durable's own extensions and neither depends on the other, but
|
|
43
43
|
* the order pins the tool-collision story should names ever overlap).
|
|
44
44
|
* Specifiers are the published package names (Task 3) — npm keys a file:
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize the extension contract's structural invocation context to the
|
|
3
|
+
* full chord Context required by pi-durable 1.4.0.
|
|
4
|
+
*
|
|
5
|
+
* Extensions deliberately depend only on HandlerContext (`abortSignal`), so
|
|
6
|
+
* they may pass a plain object. Durable publishes read telemetry and other
|
|
7
|
+
* context values, however; forwarding that literal would make the first
|
|
8
|
+
* entry/submission write fail at the host boundary. Deriving from
|
|
9
|
+
* BACKGROUND_CONTEXT supplies the runtime defaults while preserving caller
|
|
10
|
+
* cancellation. A real Context passes through BY IDENTITY so its telemetry
|
|
11
|
+
* parent and application values are never discarded.
|
|
12
|
+
*/
|
|
13
|
+
import { BACKGROUND_CONTEXT, withAbortSignal, } from "@earendil-works/chord/context";
|
|
14
|
+
/** Upgrade a structural handler context, or retain an existing chord context. */
|
|
15
|
+
export function toDurableContext(context) {
|
|
16
|
+
const candidate = context;
|
|
17
|
+
if (typeof candidate.telemetry === "object" &&
|
|
18
|
+
candidate.telemetry !== null &&
|
|
19
|
+
typeof candidate.value === "function") {
|
|
20
|
+
return context;
|
|
21
|
+
}
|
|
22
|
+
return context.abortSignal === undefined
|
|
23
|
+
? BACKGROUND_CONTEXT
|
|
24
|
+
: withAbortSignal(context.abortSignal, BACKGROUND_CONTEXT);
|
|
25
|
+
}
|
|
@@ -64,6 +64,7 @@ import { SessionMetaDoc } from "../server/session-meta.js";
|
|
|
64
64
|
import { EXPANDED_ENTRY_KIND } from "../commands/expand.js";
|
|
65
65
|
import { InteractionDoc, TERMINAL as INTERACTION_TERMINAL } from "./interactions.js";
|
|
66
66
|
import { AmbientDoc } from "./ambient.js";
|
|
67
|
+
import { toDurableContext } from "./context.js";
|
|
67
68
|
import { beginUserDeletion } from "./stores.js";
|
|
68
69
|
/** Project a SessionMeta doc value into the tracked basis. */
|
|
69
70
|
function metaBasisOf(value) {
|
|
@@ -84,9 +85,9 @@ function metaBasisOf(value) {
|
|
|
84
85
|
return basis;
|
|
85
86
|
}
|
|
86
87
|
/** How long shutdown waits for in-flight worker runs before abandoning (spec §3). */
|
|
87
|
-
const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
|
|
88
|
+
export const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
|
|
88
89
|
/** The default last-admission window (gateway spec §8.13). */
|
|
89
|
-
const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
|
|
90
|
+
export const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
|
|
90
91
|
/** Short pause between live-run checks while the commit side lane works. */
|
|
91
92
|
const SHUTDOWN_SETTLE_POLL_MS = 10;
|
|
92
93
|
/**
|
|
@@ -398,10 +399,13 @@ export class EventBus {
|
|
|
398
399
|
* 3. drain pending settles and wait (bounded) for placed live runs —
|
|
399
400
|
* a run settling during this last admission window delivers before
|
|
400
401
|
* the shutdown flush; a wedged run logs its conversation ids;
|
|
401
|
-
* 4. deliver `harnessShutdown` to every extension
|
|
402
|
-
*
|
|
403
|
-
* open in this window (the seal is `Harness.close`, the
|
|
404
|
-
* next step), which is exactly the flush window the spec
|
|
402
|
+
* 4. deliver `harnessShutdown` to every extension CONCURRENTLY under
|
|
403
|
+
* ONE shared hook deadline (D6). Handlers may commit — admission is
|
|
404
|
+
* still open in this window (the seal is `Harness.close`, the
|
|
405
|
+
* caller's next step), which is exactly the flush window the spec
|
|
406
|
+
* promises — but their relative execution and commit order is not
|
|
407
|
+
* guaranteed. A wedged handler is abandoned at the shared deadline;
|
|
408
|
+
* other handlers' completed commits remain durable;
|
|
405
409
|
* 5. close the bus (idempotent; boot's `subscribeClose` wiring makes a
|
|
406
410
|
* later `harness.close` a no-op here).
|
|
407
411
|
*/
|
|
@@ -418,11 +422,18 @@ export class EventBus {
|
|
|
418
422
|
// harnessShutdown carries no conversation: the conversation-scoped
|
|
419
423
|
// reads resolve empty and `document` serves any conversation.
|
|
420
424
|
const api = this.#apiFor(undefined);
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
425
|
+
const registered = [...this.#extensions.entries()].filter(([, handlers]) => handlers.harnessShutdown !== undefined);
|
|
426
|
+
const calls = registered.map(([name, handlers]) => this.#bounded(`extension ${name} harnessShutdown`, () => handlers.harnessShutdown(api, context)));
|
|
427
|
+
let timer;
|
|
428
|
+
const sharedDeadline = new Promise((resolve) => {
|
|
429
|
+
timer = setTimeout(resolve, Math.max(0, this.#hookDeadlineMs));
|
|
430
|
+
});
|
|
431
|
+
try {
|
|
432
|
+
await Promise.race([Promise.allSettled(calls), sharedDeadline]);
|
|
433
|
+
}
|
|
434
|
+
finally {
|
|
435
|
+
if (timer !== undefined)
|
|
436
|
+
clearTimeout(timer);
|
|
426
437
|
}
|
|
427
438
|
this.close();
|
|
428
439
|
}
|
|
@@ -1266,15 +1277,15 @@ export class EventBus {
|
|
|
1266
1277
|
return {
|
|
1267
1278
|
entry: async () => undefined,
|
|
1268
1279
|
entries: async () => ({ items: [] }),
|
|
1269
|
-
document: ((token, id, context) => harness.snapshot(token, id, context)),
|
|
1280
|
+
document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
|
|
1270
1281
|
};
|
|
1271
1282
|
}
|
|
1272
1283
|
return {
|
|
1273
1284
|
entry: (id, context) => this.#readEntry(conversationId, id, context),
|
|
1274
1285
|
entries: (query, limit, cursor, context) => this.#entriesOf(conversationId, query, limit, cursor, context),
|
|
1275
1286
|
// The snapshot overloads are generic per call; the narrowed shape
|
|
1276
|
-
// carries one conversation-doc read
|
|
1277
|
-
document: ((token, id, context) => harness.snapshot(token, id, context)),
|
|
1287
|
+
// carries one conversation-doc read with the D1 context upgrade.
|
|
1288
|
+
document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
|
|
1278
1289
|
};
|
|
1279
1290
|
}
|
|
1280
1291
|
/** `Conversation.entries` over the settled conversation (read failures resolve empty). */
|
|
@@ -35,6 +35,7 @@ const VIAS = new Set([
|
|
|
35
35
|
"self",
|
|
36
36
|
"pairing",
|
|
37
37
|
"admin",
|
|
38
|
+
"proxy",
|
|
38
39
|
]);
|
|
39
40
|
/**
|
|
40
41
|
* A `(provider, subject)` already linked to a different user. The
|
|
@@ -52,8 +53,8 @@ export class AccountLinkConflictError extends Error {
|
|
|
52
53
|
this.subject = subject;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
/** `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
|
|
56
|
-
const PROVIDER_PATTERN = /^(login|chat):[a-z0-9][a-z0-9._:-]
|
|
56
|
+
/** `proxy` / `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
|
|
57
|
+
const PROVIDER_PATTERN = /^(proxy|(login|chat):[a-z0-9][a-z0-9._:-]*)$/;
|
|
57
58
|
/** A subject is opaque, non-empty, and bounded. */
|
|
58
59
|
const SUBJECT_MAX_LENGTH = 512;
|
|
59
60
|
/**
|
|
@@ -21,7 +21,8 @@ import { AccountStore, AccountLinkConflictError, } from "./accounts.js";
|
|
|
21
21
|
import { TokenStore, generateToken, hashToken, } from "./tokens.js";
|
|
22
22
|
import { InviteStore, DEFAULT_INVITE_EXPIRY_MS, } from "./invites.js";
|
|
23
23
|
import { ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, } from "./setup-code.js";
|
|
24
|
-
|
|
24
|
+
import { sweepAccountLinks, } from "./link-sweep.js";
|
|
25
|
+
export { UserDirectory, effectiveAdmin, storedGroups, AccountStore, AccountLinkConflictError, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, sweepAccountLinks, };
|
|
25
26
|
/** The file names each store owns under its root. */
|
|
26
27
|
const USERS_FILE = "users.json";
|
|
27
28
|
const TOKENS_FILE = "api-tokens.json";
|
|
@@ -33,8 +33,17 @@ function matchesCode(record, digest) {
|
|
|
33
33
|
return false;
|
|
34
34
|
return timingSafeEqual(Buffer.from(record.sha256, "hex"), Buffer.from(digest, "hex"));
|
|
35
35
|
}
|
|
36
|
+
/** Locate the valid, unused row for one code, or undefined without effects. */
|
|
37
|
+
function validInviteIndex(records, code, now) {
|
|
38
|
+
const digest = hashToken(code);
|
|
39
|
+
const index = records.findIndex((record) => matchesCode(record, digest));
|
|
40
|
+
if (index === -1)
|
|
41
|
+
return -1;
|
|
42
|
+
const record = records[index];
|
|
43
|
+
return record.usedBy === undefined && now <= record.expiresAt ? index : -1;
|
|
44
|
+
}
|
|
36
45
|
/**
|
|
37
|
-
* The file-backed invite store. `redeem` is the single mutator;
|
|
46
|
+
* The file-backed invite store. `redeem` is the single mutator; all three
|
|
38
47
|
* methods serialize on the store's {@link MutationQueue}.
|
|
39
48
|
*/
|
|
40
49
|
export class InviteStore {
|
|
@@ -66,6 +75,19 @@ export class InviteStore {
|
|
|
66
75
|
return { id: record.id, code, expiresAt: record.expiresAt };
|
|
67
76
|
});
|
|
68
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate a code without consuming it: the same digest, single-use, and
|
|
80
|
+
* expiry rules as {@link redeem}, returning its grant while leaving the row
|
|
81
|
+
* untouched. Callers must still redeem last; this is a preflight, not a
|
|
82
|
+
* reservation against another writer.
|
|
83
|
+
*/
|
|
84
|
+
peek(code, now = Date.now()) {
|
|
85
|
+
return this.mutations.run(async () => {
|
|
86
|
+
const records = this.list();
|
|
87
|
+
const index = validInviteIndex(records, code, now);
|
|
88
|
+
return index === -1 ? undefined : { admin: records[index].admin };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
69
91
|
/**
|
|
70
92
|
* Redeem one code for `user`: undefined when no record matches the
|
|
71
93
|
* digest, when the row is already used (single-use, forever), or
|
|
@@ -77,15 +99,10 @@ export class InviteStore {
|
|
|
77
99
|
redeem(code, user, now = Date.now()) {
|
|
78
100
|
return this.mutations.run(async () => {
|
|
79
101
|
const records = this.list();
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const record = index === -1 ? undefined : records[index];
|
|
83
|
-
if (record === undefined)
|
|
84
|
-
return undefined;
|
|
85
|
-
if (record.usedBy !== undefined)
|
|
86
|
-
return undefined;
|
|
87
|
-
if (now > record.expiresAt)
|
|
102
|
+
const index = validInviteIndex(records, code, now);
|
|
103
|
+
if (index === -1)
|
|
88
104
|
return undefined;
|
|
105
|
+
const record = records[index];
|
|
89
106
|
records[index] = { ...record, usedBy: user };
|
|
90
107
|
await writeJson(this.path, records);
|
|
91
108
|
return { admin: record.admin };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The boot-time account-link sweep (username-derivation Task 8, spec
|
|
3
|
+
* §11.3): runs ONCE at boot, after the identity stores open and before
|
|
4
|
+
* the listener binds — never on the request path, and never inside the
|
|
5
|
+
* claim lock (boot is single-threaded before serving).
|
|
6
|
+
*
|
|
7
|
+
* Two repair classes:
|
|
8
|
+
*
|
|
9
|
+
* - DANGLING LINKS: an `accounts.json` row whose `user` no longer
|
|
10
|
+
* exists (a hand-edited file, or a crash between a user deletion and
|
|
11
|
+
* its link cleanup). The sweep REMOVES them — the link names an
|
|
12
|
+
* identity that cannot resolve, so keeping it only preserves a
|
|
13
|
+
* conflict block against a future re-add.
|
|
14
|
+
*
|
|
15
|
+
* - CRASH ORPHANS: a `source === "proxy"` user with NO proxy link
|
|
16
|
+
* (Task 3's crash window: the user row landed, the link write never
|
|
17
|
+
* did). The sweep LISTS them, never repairs: the original proxy
|
|
18
|
+
* username is not derivable from the directory key (collisions
|
|
19
|
+
* suffix it), so the operator repairs with
|
|
20
|
+
* `sigrun user link <key> --proxy <username>`.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Remove links whose user is gone and list proxy-source users with no
|
|
24
|
+
* proxy link. After the initial reads and the serialized unlinks, the
|
|
25
|
+
* orphan pass deliberately RE-READS accounts.json so a removed link
|
|
26
|
+
* cannot leave stale state in the orphan list.
|
|
27
|
+
*/
|
|
28
|
+
export async function sweepAccountLinks(users, accounts) {
|
|
29
|
+
const known = new Set(users.list().map((record) => record.user));
|
|
30
|
+
const links = accounts.list();
|
|
31
|
+
const dangling = links.filter((link) => !known.has(link.user));
|
|
32
|
+
let removedDangling = 0;
|
|
33
|
+
for (const link of dangling) {
|
|
34
|
+
if (await accounts.unlink(link.provider, link.subject)) {
|
|
35
|
+
removedDangling += 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const linkedProxyUsers = new Set(accounts
|
|
39
|
+
.list()
|
|
40
|
+
.filter((link) => link.provider === "proxy")
|
|
41
|
+
.map((link) => link.user));
|
|
42
|
+
const orphans = users
|
|
43
|
+
.list()
|
|
44
|
+
.filter((record) => record.source === "proxy" && !linkedProxyUsers.has(record.user))
|
|
45
|
+
.map((record) => record.user);
|
|
46
|
+
return { removedDangling, orphans };
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an operator-typed username to its canonical key. Typed entry
|
|
3
|
+
* points refuse collisions rather than silently suffixing them; the caller
|
|
4
|
+
* owns the surrounding claim/admission locking and supplies `taken`.
|
|
5
|
+
*/
|
|
6
|
+
import { deriveUsername } from "@sigrun/shared/usernames";
|
|
7
|
+
/** Derive a typed username, refusing unusable input and existing keys. */
|
|
8
|
+
export function normalizeTypedUsername(input, taken) {
|
|
9
|
+
const key = deriveUsername(input);
|
|
10
|
+
if (key === undefined)
|
|
11
|
+
return { ok: false, error: "invalid_username" };
|
|
12
|
+
if (taken(key))
|
|
13
|
+
return { ok: false, error: "username_taken" };
|
|
14
|
+
return { ok: true, key };
|
|
15
|
+
}
|
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
* after the first add (`isClaimed`); `admin` is a property of the USER
|
|
12
12
|
* (tokens carry their user's rights — never their own admin flag).
|
|
13
13
|
*/
|
|
14
|
+
import { isUsernameKey } from "@sigrun/shared/usernames";
|
|
14
15
|
import { MutationQueue, readRecords, writeJson } from "./json-store.js";
|
|
15
|
-
/** The username rule (spec §5.8): lowercase-first, then `[a-z0-9._-]`. */
|
|
16
|
-
const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
17
16
|
/**
|
|
18
17
|
* Strip the retired C8 field at every write boundary. A legacy on-disk
|
|
19
18
|
* value is readable for compatibility, but never survives another write
|
|
@@ -61,9 +60,13 @@ export class UserDirectory {
|
|
|
61
60
|
async writeRecords(records) {
|
|
62
61
|
await writeJson(this.path, records.map(withoutLegacyGroupAdmin));
|
|
63
62
|
}
|
|
64
|
-
/** Run the
|
|
63
|
+
/** Run the shared admission guard before consuming an invite. Writes nothing. */
|
|
65
64
|
checkAdmission() {
|
|
66
|
-
this.
|
|
65
|
+
this.checkAdmissionFor(this.list());
|
|
66
|
+
}
|
|
67
|
+
checkAdmissionFor(records) {
|
|
68
|
+
if (records.length >= 1)
|
|
69
|
+
this.options.admit?.(records.length);
|
|
67
70
|
}
|
|
68
71
|
/** Every user, insertion order (the first add is the onboarding claim). */
|
|
69
72
|
list() {
|
|
@@ -88,15 +91,15 @@ export class UserDirectory {
|
|
|
88
91
|
*/
|
|
89
92
|
add(user) {
|
|
90
93
|
return this.mutations.run(async () => {
|
|
91
|
-
if (!
|
|
92
|
-
throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]
|
|
94
|
+
if (!isUsernameKey(user.user)) {
|
|
95
|
+
throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]*$ and be at most 128 characters`);
|
|
93
96
|
}
|
|
94
97
|
const records = this.list();
|
|
95
98
|
if (records.some((record) => record.user === user.user)) {
|
|
96
99
|
throw new Error(`user "${user.user}" already exists`);
|
|
97
100
|
}
|
|
98
101
|
if (records.length >= 1) {
|
|
99
|
-
this.
|
|
102
|
+
this.checkAdmissionFor(records);
|
|
100
103
|
if (records.length === 1 && records[0].workspace === undefined && this.options.firstWorkspace !== undefined) {
|
|
101
104
|
records[0] = { ...records[0], workspace: this.options.firstWorkspace };
|
|
102
105
|
}
|
|
@@ -62,6 +62,7 @@ export const InteractionDoc = defineDocFamily({
|
|
|
62
62
|
kind: "sigrun.interaction",
|
|
63
63
|
version: 1,
|
|
64
64
|
scope: "conversation",
|
|
65
|
+
checkpointWhen: () => false,
|
|
65
66
|
history: "latest",
|
|
66
67
|
fork: "current",
|
|
67
68
|
family: true,
|
|
@@ -76,6 +77,7 @@ export const InteractionsIndexDoc = defineDoc({
|
|
|
76
77
|
kind: "sigrun.interactions.index",
|
|
77
78
|
version: 1,
|
|
78
79
|
scope: "session",
|
|
80
|
+
checkpointWhen: () => false,
|
|
79
81
|
initial: () => ({ pending: {} }),
|
|
80
82
|
});
|
|
81
83
|
/** Terminal statuses: answering one of these is a no-op (first wins).
|