@sigrun/escalations 0.0.0-stage.1 → 0.1.1

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 CHANGED
@@ -5,6 +5,29 @@ 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.1.1] - 2026-11-03
9
+ ### Fixed
10
+ - `reopenReason` is cleared on forward transitions (`resolve` done,
11
+ `dismiss`, `restore`) so a stale reopen badge no longer survives onto
12
+ resolved or dismissed items.
13
+ - The seeded run text renders a deleted job's source as its bare id
14
+ (`job "api:1"`) instead of the redundant `job "api:1" (api:1)`.
15
+ - Bulk inbox toasts pluralize (`Dismissed 2 escalations`, not the
16
+ invented `escalation(s)` form).
17
+ - The findVisible-then-update race in inbox actions throws a typed
18
+ `TransitionError` (`not_found`) and renders the same not-found body as
19
+ an unknown id.
20
+ - The 15-minute sweep dedupes `jobs.get` lookups across buckets, stops
21
+ cooperatively between buckets when the task's abort signal fires, and
22
+ signals `changedInbox` for migrated items.
23
+ - `transferItems` no-ops on a same-bucket call and returns the moved
24
+ ids.
25
+ - `userDeleted` transfer history is stamped with the injected clock.
26
+
27
+ ### Changed
28
+ - The factory accepts an optional `{ now }` clock for tests (arity one
29
+ is preserved for the loader).
30
+
8
31
  ## [Unreleased]
9
32
  ### Added
10
33
  - The escalation extension factory with `escalate`, `escalation_list`, and
package/dist/dispatch.js CHANGED
@@ -8,8 +8,11 @@ function errorMessage(error) {
8
8
  return error instanceof Error ? error.message : String(error);
9
9
  }
10
10
  function sourceText(e, jobName) {
11
- if (e.source.jobId !== undefined)
12
- return `job "${jobName ?? e.source.jobId}" (${e.source.jobId})`;
11
+ if (e.source.jobId !== undefined) {
12
+ return jobName === undefined
13
+ ? `job "${e.source.jobId}"`
14
+ : `job "${jobName}" (${e.source.jobId})`;
15
+ }
13
16
  if (e.source.conversationId !== undefined)
14
17
  return `conversation #${e.source.conversationId}`;
15
18
  return "none";
package/dist/inbox.js CHANGED
@@ -398,7 +398,7 @@ export function createInboxView(deps) {
398
398
  const next = items.map((item) => (item.id === id ? apply(item) : item));
399
399
  const updated = next.find((item) => item.id === id);
400
400
  if (updated === undefined)
401
- throw new Error("Escalation not found");
401
+ throw new TransitionError("not_found");
402
402
  return { items: next, result: updated };
403
403
  });
404
404
  }
@@ -420,6 +420,11 @@ export function createInboxView(deps) {
420
420
  };
421
421
  }
422
422
  catch (error) {
423
+ // The item vanished between findVisible and the update: the same
424
+ // not-found body as an unknown id, never a raw error.
425
+ if (error instanceof TransitionError && error.code === "not_found") {
426
+ return notFound();
427
+ }
423
428
  return failure(error);
424
429
  }
425
430
  }
@@ -440,7 +445,7 @@ export function createInboxView(deps) {
440
445
  // An invalid id or an inapplicable status skips that item.
441
446
  }
442
447
  }
443
- return { ok: true, toast: `${label} ${count} escalation(s)` };
448
+ return { ok: true, toast: `${label} ${count} escalation${count === 1 ? "" : "s"}` };
444
449
  }
445
450
  return {
446
451
  id: "inbox",
package/dist/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import type { SigrunExtensionResult, SigrunHost } from "@sigrun/sigrun/extension";
2
+ export interface EscalationsOptions {
3
+ /** Injectable clock for tests; every timestamp the extension stamps. */
4
+ readonly now?: () => Date;
5
+ }
2
6
  /**
3
7
  * Build the complete escalation surface over one shared durable store.
4
8
  * The required host parameter keeps the factory at arity one for the loader.
5
9
  */
6
- export default function createEscalations(host: SigrunHost): SigrunExtensionResult;
10
+ export default function createEscalations(host: SigrunHost, options?: EscalationsOptions): SigrunExtensionResult;
package/dist/index.js CHANGED
@@ -10,9 +10,9 @@ import { createTools } from "./tools.js";
10
10
  * Build the complete escalation surface over one shared durable store.
11
11
  * The required host parameter keeps the factory at arity one for the loader.
12
12
  */
13
- export default function createEscalations(host) {
13
+ export default function createEscalations(host, options = {}) {
14
14
  const store = new EscalationStore(host);
15
- const deps = { host, store };
15
+ const deps = { host, store, now: options.now };
16
16
  return {
17
17
  extension: defineExtension({
18
18
  name: "escalations",
@@ -30,7 +30,7 @@ export default function createEscalations(host) {
30
30
  // (src/server/routes/users.ts:303-305): host.stores.user(u) throws for
31
31
  // unknown users, so both bucket paths must be available here.
32
32
  const sourceItems = await store.list(source);
33
- await transferItems(store, source, target, new Set(sourceItems.map((item) => item.id)), new Date());
33
+ await transferItems(store, source, target, new Set(sourceItems.map((item) => item.id)), deps.now?.() ?? new Date());
34
34
  },
35
35
  };
36
36
  }
package/dist/lifecycle.js CHANGED
@@ -71,7 +71,9 @@ export function resolve(e, conversationId, outcome, note, now) {
71
71
  throw new TransitionError("wrong_claimant");
72
72
  }
73
73
  if (outcome === "done") {
74
- return changed(e, now, { event: "resolved", note }, { status: "resolved", resolvedAt: now.toISOString() });
74
+ // Forward transition: the stale reopen badge must not survive onto
75
+ // the terminal resolved state (inbox.ts renders it as a warn badge).
76
+ return changed(e, now, { event: "resolved", note }, { status: "resolved", resolvedAt: now.toISOString(), reopenReason: undefined });
75
77
  }
76
78
  return changed(e, now, {
77
79
  event: "failed",
@@ -113,12 +115,12 @@ export function dismiss(e, now) {
113
115
  if (e.status !== "open" && e.status !== "answered") {
114
116
  throw new TransitionError("not_dismissable");
115
117
  }
116
- return changed(e, now, { event: "dismissed" }, { status: "dismissed", answer: undefined });
118
+ return changed(e, now, { event: "dismissed" }, { status: "dismissed", answer: undefined, reopenReason: undefined });
117
119
  }
118
120
  export function restore(e, now) {
119
121
  if (e.status !== "dismissed")
120
122
  throw new TransitionError("not_dismissed");
121
- return changed(e, now, { event: "restored" }, { status: "open" });
123
+ return changed(e, now, { event: "restored" }, { status: "open", reopenReason: undefined });
122
124
  }
123
125
  export function snooze(e, days, now) {
124
126
  if (!Number.isInteger(days) || days < 1 || days > 365) {
package/dist/store.js CHANGED
@@ -41,6 +41,10 @@ export async function bucketFor(host, owner) {
41
41
  if (owner !== undefined && (await host.users.get(owner)) !== undefined) {
42
42
  return { kind: "user", user: owner };
43
43
  }
44
+ // The primary-admin fallback. Core guarantees a non-empty directory
45
+ // holds at least one admin (last_admin 409 guards on both demote and
46
+ // delete, src/server/routes/users.ts); the shared bucket is reached
47
+ // only by the empty directory, where list() is empty.
44
48
  const primaryAdmin = (await host.users.list()).find((user) => user.admin);
45
49
  return primaryAdmin === undefined
46
50
  ? { kind: "shared" }
package/dist/sweep.d.ts CHANGED
@@ -34,11 +34,15 @@ export interface SweepReport {
34
34
  claimsExpired: number;
35
35
  pruned: number;
36
36
  errors: string[];
37
+ /** Buckets skipped after the abort signal fired. */
38
+ stops?: number;
37
39
  }
38
40
  export interface SweepDeps {
39
41
  readonly host: SigrunHost;
40
42
  readonly store: EscalationStore;
41
43
  readonly now?: () => Date;
44
+ /** Cooperative cancellation, checked between buckets (the task phase passes the invocation's runtime.signal). */
45
+ readonly signal?: AbortSignal;
42
46
  }
43
47
  /** The sweep body, shared by the task phase and directly unit-testable. */
44
48
  export declare function runSweep(deps: SweepDeps): Promise<SweepReport>;
package/dist/sweep.js CHANGED
@@ -37,26 +37,38 @@ function jobIdOf(e) {
37
37
  : undefined;
38
38
  }
39
39
  /** Read-only planning pass: which jobs exist. Never holds the bucket queue. */
40
- async function planSweep(deps, bucket) {
40
+ async function planSweep(deps, bucket, cache) {
41
41
  const items = await deps.store.list(bucket);
42
42
  const jobs = new Map();
43
43
  for (const item of items) {
44
44
  const jobId = jobIdOf(item);
45
45
  if (jobId === undefined || jobs.has(jobId))
46
46
  continue;
47
- jobs.set(jobId, await deps.host.jobs.get(jobId));
47
+ // The cache is sweep-wide: a job that spans two buckets is looked
48
+ // up once, since host.jobs.get is idempotent.
49
+ const cached = cache.get(jobId);
50
+ if (cached !== undefined || cache.has(jobId)) {
51
+ jobs.set(jobId, cached);
52
+ continue;
53
+ }
54
+ const job = await deps.host.jobs.get(jobId);
55
+ cache.set(jobId, job);
56
+ jobs.set(jobId, job);
48
57
  }
49
58
  return { items, jobs };
50
59
  }
51
60
  /** Reconcile job-owned items into the buckets of their jobs' current owners. */
52
61
  async function migrateTransferredJobs(deps, bucket, plan, now) {
53
62
  const owner = bucket.kind === "user" ? bucket.user : undefined;
63
+ const moved = [];
54
64
  for (const [jobId, job] of plan.jobs) {
55
65
  if (job === undefined || job.owner === owner)
56
66
  continue;
57
67
  const ids = new Set(plan.items.filter((item) => jobIdOf(item) === jobId).map((item) => item.id));
58
- await transferItems(deps.store, bucket, await bucketFor(deps.host, job.owner), ids, now);
68
+ const transferred = await transferItems(deps.store, bucket, await bucketFor(deps.host, job.owner), ids, now);
69
+ moved.push(...transferred);
59
70
  }
71
+ return moved;
60
72
  }
61
73
  /** One atomic lifecycle update per source bucket: the four phases in order. */
62
74
  async function sweepBucket(deps, bucket, plan, now) {
@@ -114,10 +126,18 @@ export async function runSweep(deps) {
114
126
  pruned: 0,
115
127
  errors: [],
116
128
  };
129
+ const jobs = new Map();
117
130
  for (const bucket of await allBuckets(deps.host)) {
131
+ if (deps.signal?.aborted === true) {
132
+ // Cooperative stop: the harness is closing or the task was
133
+ // aborted; the next 15-minute tick sweeps the rest. `stops`
134
+ // is set only on a real stop, keeping the base report shape.
135
+ report.stops = (report.stops ?? 0) + 1;
136
+ continue;
137
+ }
118
138
  try {
119
- const plan = await planSweep(deps, bucket);
120
- await migrateTransferredJobs(deps, bucket, plan, now);
139
+ const plan = await planSweep(deps, bucket, jobs);
140
+ const moved = await migrateTransferredJobs(deps, bucket, plan, now);
121
141
  const outcome = await sweepBucket(deps, bucket, plan, now);
122
142
  report.snoozesEnded += outcome.report.snoozesEnded;
123
143
  report.orphaned += outcome.report.orphaned;
@@ -132,6 +152,10 @@ export async function runSweep(deps) {
132
152
  await notifyOrphaned(deps.host, item);
133
153
  changedInbox(deps.host, item.id);
134
154
  }
155
+ // Migrated items changed bucket contents like a deletion does;
156
+ // wake the inbox instances so the moved items render at once.
157
+ for (const id of moved)
158
+ changedInbox(deps.host, id);
135
159
  }
136
160
  catch (error) {
137
161
  const path = deps.store.fileOf(bucket);
@@ -150,7 +174,7 @@ export function createSweepTask(deps) {
150
174
  initial: () => ({ phase: "sweep" }),
151
175
  phases: {
152
176
  sweep: async (_task, runtime, context) => {
153
- await runSweep(deps);
177
+ await runSweep({ ...deps, signal: runtime.signal });
154
178
  // REQUIRED: a phase returning without durable progress faults the
155
179
  // task. The sweep's bookkeeping lives in the store files; the
156
180
  // task's own terminal outcome is the durable progress.
package/dist/tools.js CHANGED
@@ -228,6 +228,9 @@ export function createTools(deps) {
228
228
  next[index] = resolved;
229
229
  return { items: next, result: resolved };
230
230
  });
231
+ // The model reads these texts; keep them pinned in sync with
232
+ // the exact seedText expectation in dispatch.test.ts, which
233
+ // names escalation_resolve as the resolution path.
231
234
  if (args.outcome === "failed")
232
235
  await notifyReopened(deps.host, item);
233
236
  changedInbox(deps.host, item.id);
@@ -5,4 +5,4 @@ import type { Bucket } from "./types.js";
5
5
  * A crash between the updates leaves duplicates; the id check makes retrying
6
6
  * the copy idempotent, so a later delivery can finish without losing items.
7
7
  */
8
- export declare function transferItems(store: EscalationStore, source: Bucket, target: Bucket, ids: ReadonlySet<string>, now: Date): Promise<void>;
8
+ export declare function transferItems(store: EscalationStore, source: Bucket, target: Bucket, ids: ReadonlySet<string>, now: Date): Promise<readonly string[]>;
package/dist/transfer.js CHANGED
@@ -4,11 +4,18 @@
4
4
  * the copy idempotent, so a later delivery can finish without losing items.
5
5
  */
6
6
  export async function transferItems(store, source, target, ids, now) {
7
+ // Same-bucket transfer is a no-op; unreachable through bucketFor (a
8
+ // job's owner resolves to its own bucket), but the guard keeps the
9
+ // copy-then-remove shape honest for direct callers.
10
+ if ((source.kind === "shared" && target.kind === "shared") ||
11
+ (source.kind === "user" && target.kind === "user" && source.user === target.user)) {
12
+ return [];
13
+ }
7
14
  if (ids.size === 0)
8
- return;
15
+ return [];
9
16
  const sourceItems = (await store.list(source)).filter((item) => ids.has(item.id));
10
17
  if (sourceItems.length === 0)
11
- return;
18
+ return [];
12
19
  const sourceOwner = source.kind === "user" ? source.user : "shared";
13
20
  const targetOwner = target.kind === "user" ? target.user : undefined;
14
21
  const idsInTarget = await store.update(target, (targetItems) => {
@@ -38,4 +45,5 @@ export async function transferItems(store, source, target, ids, now) {
38
45
  items: items.filter((item) => !idsInTarget.has(item.id)),
39
46
  result: undefined,
40
47
  }));
48
+ return [...idsInTarget].filter((id) => ids.has(id));
41
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/escalations",
3
- "version": "0.0.0-stage.1",
3
+ "version": "0.1.1",
4
4
  "description": "Escalation inbox extension for sigrun: async decisions for jobs and agents",
5
5
  "type": "module",
6
6
  "engines": {