@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 +23 -0
- package/dist/dispatch.js +5 -2
- package/dist/inbox.js +7 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -3
- package/dist/lifecycle.js +5 -3
- package/dist/store.js +4 -0
- package/dist/sweep.d.ts +4 -0
- package/dist/sweep.js +30 -6
- package/dist/tools.js +3 -0
- package/dist/transfer.d.ts +1 -1
- package/dist/transfer.js +10 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/dist/transfer.d.ts
CHANGED
|
@@ -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<
|
|
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
|
}
|