@sigrun/escalations 0.0.0-stage → 0.1.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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * JsonFile<T> — one serialized, atomically replaced JSON state file.
3
+ *
4
+ * Updates run read → mutate → write in one queue position. Writes use a
5
+ * same-directory temp file and rename, and are always mode 0600. Malformed
6
+ * JSON throws naming the path rather than being silently reset.
7
+ *
8
+ * The queue is PER INSTANCE: users sharing a path must share the JsonFile.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { chmod, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
12
+ import { dirname } from "node:path";
13
+ export class JsonFile {
14
+ path;
15
+ initial;
16
+ queue = Promise.resolve();
17
+ constructor(path, initial) {
18
+ this.path = path;
19
+ this.initial = initial;
20
+ }
21
+ read() {
22
+ return this.enqueue(() => this.readNow());
23
+ }
24
+ /**
25
+ * Return the next state from the current one. Returning the same reference
26
+ * is a no-op: the read is serialized, but no replacement is written.
27
+ */
28
+ update(mutate) {
29
+ return this.enqueue(async () => {
30
+ const current = await this.readNow();
31
+ const next = await mutate(current);
32
+ if (next !== current)
33
+ await this.writeNow(next);
34
+ return next;
35
+ });
36
+ }
37
+ enqueue(work) {
38
+ const next = this.queue.then(work, work);
39
+ // A rejected operation does not poison later reads or updates.
40
+ this.queue = next.catch(() => { });
41
+ return next;
42
+ }
43
+ async readNow() {
44
+ let text;
45
+ try {
46
+ text = await readFile(this.path, "utf8");
47
+ }
48
+ catch (error) {
49
+ if (error.code === "ENOENT") {
50
+ return this.initial();
51
+ }
52
+ throw error;
53
+ }
54
+ try {
55
+ return JSON.parse(text);
56
+ }
57
+ catch (error) {
58
+ throw new Error(`escalations: malformed JSON in ${this.path}: ${String(error)}`, { cause: error });
59
+ }
60
+ }
61
+ async writeNow(value) {
62
+ await mkdir(dirname(this.path), { recursive: true });
63
+ const temp = `${this.path}.${randomUUID()}.tmp`;
64
+ try {
65
+ await writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, {
66
+ mode: 0o600,
67
+ });
68
+ await chmod(temp, 0o600);
69
+ await rename(temp, this.path);
70
+ }
71
+ catch (error) {
72
+ await rm(temp, { force: true }).catch(() => {
73
+ // Keep the original write failure if cleanup also fails.
74
+ });
75
+ throw error;
76
+ }
77
+ }
78
+ }
package/dist/keys.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
2
+ export declare const INBOX_TABS: readonly ["open", "active", "dismissed", "resolved"];
3
+ /** Every inbox instance whose contents can change for this record. */
4
+ export declare function inboxKeys(id?: string): string[];
5
+ /** Notify the surface host; this is deliberately fire-and-forget per its contract. */
6
+ export declare function changedInbox(host: SigrunHost, id?: string): void;
package/dist/keys.js ADDED
@@ -0,0 +1,14 @@
1
+ export const INBOX_TABS = ["open", "active", "dismissed", "resolved"];
2
+ /** Every inbox instance whose contents can change for this record. */
3
+ export function inboxKeys(id) {
4
+ return [
5
+ "{}",
6
+ ...INBOX_TABS.map((tab) => `{"tab":"${tab}"}`),
7
+ ...(id === undefined ? [] : [`{"id":${JSON.stringify(id)}}`]),
8
+ ];
9
+ }
10
+ /** Notify the surface host; this is deliberately fire-and-forget per its contract. */
11
+ export function changedInbox(host, id) {
12
+ for (const key of inboxKeys(id))
13
+ host.surfaces.changed("inbox", key);
14
+ }
@@ -0,0 +1,24 @@
1
+ import { type Escalation, type RaiseInput } from "./types.js";
2
+ /** A disallowed lifecycle transition, identified by a stable machine code. */
3
+ export declare class TransitionError extends Error {
4
+ readonly code: string;
5
+ constructor(code: string);
6
+ }
7
+ export declare function raise(input: RaiseInput, now: Date): Escalation;
8
+ export declare function answer(e: Escalation, a: {
9
+ option?: string;
10
+ instruction?: string;
11
+ by?: string;
12
+ }, now: Date): Escalation;
13
+ export declare function withdraw(e: Escalation, now: Date): Escalation;
14
+ export declare function claim(e: Escalation, conversationId: number, now: Date): Escalation;
15
+ export declare function resolve(e: Escalation, conversationId: number, outcome: "done" | "failed", note: string, now: Date): Escalation;
16
+ /** Reopen an unclaimed answer when its immediate action could not start. */
17
+ export declare function reopen(e: Escalation, note: string, now: Date): Escalation;
18
+ export declare function release(e: Escalation, conversationId: number, note: string, now: Date): Escalation;
19
+ export declare function dismiss(e: Escalation, now: Date): Escalation;
20
+ export declare function restore(e: Escalation, now: Date): Escalation;
21
+ export declare function snooze(e: Escalation, days: number, now: Date): Escalation;
22
+ export declare function endSnooze(e: Escalation, now: Date): Escalation;
23
+ export declare function orphan(e: Escalation, now: Date): Escalation;
24
+ export declare function isSkip(e: Escalation): boolean;
@@ -0,0 +1,159 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SKIP_STATUSES, } from "./types.js";
3
+ /** A disallowed lifecycle transition, identified by a stable machine code. */
4
+ export class TransitionError extends Error {
5
+ code;
6
+ constructor(code) {
7
+ super(code);
8
+ this.name = "TransitionError";
9
+ this.code = code;
10
+ }
11
+ }
12
+ function changed(e, now, entry, updates) {
13
+ return {
14
+ ...e,
15
+ ...updates,
16
+ updatedAt: now.toISOString(),
17
+ history: [...e.history, { at: now.toISOString(), ...entry }],
18
+ };
19
+ }
20
+ export function raise(input, now) {
21
+ const at = now.toISOString();
22
+ return {
23
+ ...input,
24
+ id: randomUUID(),
25
+ status: "open",
26
+ createdAt: at,
27
+ updatedAt: at,
28
+ history: [{ at, event: "raised" }],
29
+ };
30
+ }
31
+ export function answer(e, a, now) {
32
+ if (e.status !== "open")
33
+ throw new TransitionError("not_open");
34
+ if (e.orphaned)
35
+ throw new TransitionError("orphaned");
36
+ if (a.option === undefined && a.instruction === undefined) {
37
+ throw new TransitionError("empty_answer");
38
+ }
39
+ if (a.option !== undefined && !e.options.some((option) => option.id === a.option)) {
40
+ throw new TransitionError("unknown_option");
41
+ }
42
+ if (a.instruction !== undefined && !e.allowInstruction) {
43
+ throw new TransitionError("instruction_not_allowed");
44
+ }
45
+ const nextAnswer = {
46
+ ...(a.option === undefined ? {} : { option: a.option }),
47
+ ...(a.instruction === undefined ? {} : { instruction: a.instruction }),
48
+ ...(a.by === undefined ? {} : { by: a.by }),
49
+ at: now.toISOString(),
50
+ };
51
+ return changed(e, now, { event: "answered", answer: nextAnswer }, { status: "answered", answer: nextAnswer });
52
+ }
53
+ export function withdraw(e, now) {
54
+ if (e.status !== "answered")
55
+ throw new TransitionError("not_answered");
56
+ return changed(e, now, { event: "withdrawn", ...(e.answer === undefined ? {} : { answer: e.answer }) }, { status: "open", answer: undefined });
57
+ }
58
+ export function claim(e, conversationId, now) {
59
+ if (e.status === "claimed" && e.claim?.conversationId === conversationId) {
60
+ return e;
61
+ }
62
+ if (e.status !== "answered")
63
+ throw new TransitionError("not_claimable");
64
+ return changed(e, now, { event: "claimed" }, { status: "claimed", claim: { conversationId, at: now.toISOString() } });
65
+ }
66
+ export function resolve(e, conversationId, outcome, note, now) {
67
+ if (e.status !== "claimed" || e.claim === undefined) {
68
+ throw new TransitionError("not_claimed");
69
+ }
70
+ if (e.claim.conversationId !== conversationId) {
71
+ throw new TransitionError("wrong_claimant");
72
+ }
73
+ if (outcome === "done") {
74
+ return changed(e, now, { event: "resolved", note }, { status: "resolved", resolvedAt: now.toISOString() });
75
+ }
76
+ return changed(e, now, {
77
+ event: "failed",
78
+ note,
79
+ ...(e.answer === undefined ? {} : { answer: e.answer }),
80
+ }, {
81
+ status: "open",
82
+ answer: undefined,
83
+ claim: undefined,
84
+ reopenReason: "failed",
85
+ });
86
+ }
87
+ /** Reopen an unclaimed answer when its immediate action could not start. */
88
+ export function reopen(e, note, now) {
89
+ if (e.status !== "answered")
90
+ throw new TransitionError("not_reopenable");
91
+ return changed(e, now, {
92
+ event: "interrupted",
93
+ note,
94
+ ...(e.answer === undefined ? {} : { answer: e.answer }),
95
+ }, { status: "open", answer: undefined, reopenReason: "interrupted" });
96
+ }
97
+ export function release(e, conversationId, note, now) {
98
+ if (e.status !== "claimed" || e.claim?.conversationId !== conversationId) {
99
+ throw new TransitionError("not_claimed");
100
+ }
101
+ return changed(e, now, {
102
+ event: "interrupted",
103
+ note,
104
+ ...(e.answer === undefined ? {} : { answer: e.answer }),
105
+ }, {
106
+ status: "open",
107
+ answer: undefined,
108
+ claim: undefined,
109
+ reopenReason: "interrupted",
110
+ });
111
+ }
112
+ export function dismiss(e, now) {
113
+ if (e.status !== "open" && e.status !== "answered") {
114
+ throw new TransitionError("not_dismissable");
115
+ }
116
+ return changed(e, now, { event: "dismissed" }, { status: "dismissed", answer: undefined });
117
+ }
118
+ export function restore(e, now) {
119
+ if (e.status !== "dismissed")
120
+ throw new TransitionError("not_dismissed");
121
+ return changed(e, now, { event: "restored" }, { status: "open" });
122
+ }
123
+ export function snooze(e, days, now) {
124
+ if (!Number.isInteger(days) || days < 1 || days > 365) {
125
+ throw new TransitionError("bad_days");
126
+ }
127
+ if (e.status !== "open" && e.status !== "answered") {
128
+ throw new TransitionError("not_snoozable");
129
+ }
130
+ if (e.orphaned)
131
+ throw new TransitionError("orphaned");
132
+ return changed(e, now, { event: "snoozed" }, {
133
+ status: "snoozed",
134
+ answer: undefined,
135
+ snoozedUntil: new Date(now.getTime() + days * 86_400_000).toISOString(),
136
+ });
137
+ }
138
+ export function endSnooze(e, now) {
139
+ if (e.status !== "snoozed" ||
140
+ e.snoozedUntil === undefined ||
141
+ e.snoozedUntil > now.toISOString()) {
142
+ return e;
143
+ }
144
+ return changed(e, now, { event: "snooze-ended" }, { status: "open", snoozedUntil: undefined, reopenReason: "snooze-ended" });
145
+ }
146
+ export function orphan(e, now) {
147
+ if (e.status === "resolved" || e.status === "dismissed" || e.orphaned) {
148
+ return e;
149
+ }
150
+ return changed(e, now, { event: "job-deleted", note: "job deleted" }, {
151
+ status: e.status === "answered" ? "open" : e.status,
152
+ answer: e.status === "answered" ? undefined : e.answer,
153
+ orphaned: true,
154
+ reopenReason: "job-deleted",
155
+ });
156
+ }
157
+ export function isSkip(e) {
158
+ return SKIP_STATUSES.includes(e.status);
159
+ }
@@ -0,0 +1,5 @@
1
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
2
+ import type { Escalation } from "./types.js";
3
+ export declare function notifyRaised(host: SigrunHost, e: Escalation): Promise<void>;
4
+ export declare function notifyReopened(host: SigrunHost, e: Escalation): Promise<void>;
5
+ export declare function notifyOrphaned(host: SigrunHost, e: Escalation): Promise<void>;
package/dist/notify.js ADDED
@@ -0,0 +1,24 @@
1
+ async function notify(host, e, kind, title) {
2
+ const input = {
3
+ user: e.owner,
4
+ kind,
5
+ title,
6
+ body: e.body.slice(0, 200),
7
+ target: { view: "inbox", params: { id: e.id } },
8
+ };
9
+ try {
10
+ await host.notify(input);
11
+ }
12
+ catch (error) {
13
+ console.warn(`escalations: ${kind} notification failed for ${e.id}`, error);
14
+ }
15
+ }
16
+ export async function notifyRaised(host, e) {
17
+ await notify(host, e, "escalation.raised", `Needs your decision: ${e.title}`);
18
+ }
19
+ export async function notifyReopened(host, e) {
20
+ await notify(host, e, "escalation.reopened", `Back in your inbox: ${e.title}`);
21
+ }
22
+ export async function notifyOrphaned(host, e) {
23
+ await notify(host, e, "escalation.orphaned", `Job deleted: ${e.title}`);
24
+ }
@@ -0,0 +1,2 @@
1
+ export declare const ESCALATIONS_SECTION_TEXT = "Escalations: when something needs the user's decision and has no prescribed way forward, call `escalate` instead of guessing. Make the body self-contained: the evidence, the exact action you propose, and the alternatives \u2014 whoever acts on the answer may not have this conversation. Offer the possible actions as options; never add \"do nothing\" or \"later\" options \u2014 the user has Dismiss and Snooze for those. In a scheduled job run, call `escalation_list` first: skip every key under `skip`, carry out each item under `act`, and call `escalation_resolve` for each before you finish.";
2
+ export declare const escalationsSection: import("@earendil-works/pi-durable").PromptSection<import("@earendil-works/pi-durable").ToolRegistration>;
@@ -0,0 +1,5 @@
1
+ import { section } from "@earendil-works/pi-durable";
2
+ export const ESCALATIONS_SECTION_TEXT = "Escalations: when something needs the user's decision and has no prescribed way forward, call `escalate` instead of guessing. Make the body self-contained: the evidence, the exact action you propose, and the alternatives — whoever acts on the answer may not have this conversation. Offer the possible actions as options; never add \"do nothing\" or \"later\" options — the user has Dismiss and Snooze for those. In a scheduled job run, call `escalation_list` first: skip every key under `skip`, carry out each item under `act`, and call `escalation_resolve` for each before you finish.";
3
+ export const escalationsSection = section("escalations", (input) => input.agent.tools.some((tool) => tool.name === "escalate")
4
+ ? ESCALATIONS_SECTION_TEXT
5
+ : undefined);
@@ -0,0 +1,22 @@
1
+ import type { Principal, SigrunHost } from "@sigrun/sigrun/extension";
2
+ import type { Bucket, Escalation } from "./types.js";
3
+ /** Durable per-bucket escalation storage. */
4
+ export declare class EscalationStore {
5
+ private readonly host;
6
+ private readonly files;
7
+ constructor(host: SigrunHost);
8
+ fileOf(bucket: Bucket): string;
9
+ list(bucket: Bucket): Promise<Escalation[]>;
10
+ update<T>(bucket: Bucket, fn: (items: Escalation[]) => {
11
+ items: Escalation[];
12
+ result: T;
13
+ }): Promise<T>;
14
+ private fileFor;
15
+ }
16
+ export declare function bucketFor(host: SigrunHost, owner: string | undefined): Promise<Bucket>;
17
+ export declare function bucketsVisibleTo(host: SigrunHost, principal: Principal): Promise<Bucket[]>;
18
+ export declare function allBuckets(host: SigrunHost): Promise<Bucket[]>;
19
+ export declare function findVisible(store: EscalationStore, buckets: readonly Bucket[], id: string): Promise<{
20
+ bucket: Bucket;
21
+ item: Escalation;
22
+ } | undefined>;
package/dist/store.js ADDED
@@ -0,0 +1,71 @@
1
+ import { join } from "node:path";
2
+ import { JsonFile } from "./json-file.js";
3
+ /** Durable per-bucket escalation storage. */
4
+ export class EscalationStore {
5
+ host;
6
+ // One JsonFile per path is essential: its serialization queue is per
7
+ // instance, so constructing one per update would permit lost updates.
8
+ files = new Map();
9
+ constructor(host) {
10
+ this.host = host;
11
+ }
12
+ fileOf(bucket) {
13
+ return join(bucket.kind === "user"
14
+ ? this.host.stores.user(bucket.user).paths.data
15
+ : this.host.stores.paths.data, "escalations.json");
16
+ }
17
+ list(bucket) {
18
+ return this.fileFor(bucket).read();
19
+ }
20
+ async update(bucket, fn) {
21
+ let result;
22
+ await this.fileFor(bucket).update((items) => {
23
+ const next = fn(items);
24
+ result = next.result;
25
+ // JsonFile treats an unchanged array reference as the no-write fast path.
26
+ return next.items;
27
+ });
28
+ return result;
29
+ }
30
+ fileFor(bucket) {
31
+ const path = this.fileOf(bucket);
32
+ let file = this.files.get(path);
33
+ if (file === undefined) {
34
+ file = new JsonFile(path, () => []);
35
+ this.files.set(path, file);
36
+ }
37
+ return file;
38
+ }
39
+ }
40
+ export async function bucketFor(host, owner) {
41
+ if (owner !== undefined && (await host.users.get(owner)) !== undefined) {
42
+ return { kind: "user", user: owner };
43
+ }
44
+ const primaryAdmin = (await host.users.list()).find((user) => user.admin);
45
+ return primaryAdmin === undefined
46
+ ? { kind: "shared" }
47
+ : { kind: "user", user: primaryAdmin.user };
48
+ }
49
+ export async function bucketsVisibleTo(host, principal) {
50
+ // Core's naive-loopback superuser and service token carry no user
51
+ // (src/server/auth/principal.ts:27-30), so they see the shared bucket.
52
+ if (principal.user === undefined)
53
+ return [{ kind: "shared" }];
54
+ return (await host.users.get(principal.user)) === undefined
55
+ ? []
56
+ : [{ kind: "user", user: principal.user }];
57
+ }
58
+ export async function allBuckets(host) {
59
+ return [
60
+ ...(await host.users.list()).map((user) => ({ kind: "user", user: user.user })),
61
+ { kind: "shared" },
62
+ ];
63
+ }
64
+ export async function findVisible(store, buckets, id) {
65
+ for (const bucket of buckets) {
66
+ const item = (await store.list(bucket)).find((candidate) => candidate.id === id);
67
+ if (item !== undefined)
68
+ return { bucket, item };
69
+ }
70
+ return undefined;
71
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The escalation sweep task (`escalations.sweep`), fired by the `sweep`
3
+ * cron job every 15 minutes. Pure host calls + store updates: no model,
4
+ * no transcript content.
5
+ *
6
+ * Job transfers first copy their items to the current owner's bucket,
7
+ * then remove them from the source. Each remaining source bucket gets ONE
8
+ * lifecycle update: end due snoozes, orphan items whose job was deleted,
9
+ * release claims older than 24h, and prune resolved items older than 90
10
+ * days. Job lookups are PREFETCHED before entering updates so no bucket
11
+ * queue is held across harness reads.
12
+ * Notifications and inbox-change signals fire AFTER the update, for
13
+ * reopened/orphaned items only. A failing bucket lands in `errors` and
14
+ * the sweep continues to the next bucket.
15
+ */
16
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
17
+ import { EscalationStore } from "./store.js";
18
+ /** The registered task kind; the cron job's action names it. */
19
+ export declare const SWEEP_TASK_NAME = "escalations.sweep";
20
+ /** The 15-minute cron job the factory declares. */
21
+ export declare const SWEEP_JOB: {
22
+ readonly name: "sweep";
23
+ readonly schedule: {
24
+ readonly every: "15m";
25
+ };
26
+ readonly action: {
27
+ readonly kind: "task";
28
+ readonly task: "escalations.sweep";
29
+ };
30
+ };
31
+ export interface SweepReport {
32
+ snoozesEnded: number;
33
+ orphaned: number;
34
+ claimsExpired: number;
35
+ pruned: number;
36
+ errors: string[];
37
+ }
38
+ export interface SweepDeps {
39
+ readonly host: SigrunHost;
40
+ readonly store: EscalationStore;
41
+ readonly now?: () => Date;
42
+ }
43
+ /** The sweep body, shared by the task phase and directly unit-testable. */
44
+ export declare function runSweep(deps: SweepDeps): Promise<SweepReport>;
45
+ /** The task object the extension registers. */
46
+ export declare function createSweepTask(deps: SweepDeps): import("@earendil-works/pi-durable").Task<unknown, {
47
+ phase: "sweep";
48
+ }, unknown, object>;
package/dist/sweep.js ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The escalation sweep task (`escalations.sweep`), fired by the `sweep`
3
+ * cron job every 15 minutes. Pure host calls + store updates: no model,
4
+ * no transcript content.
5
+ *
6
+ * Job transfers first copy their items to the current owner's bucket,
7
+ * then remove them from the source. Each remaining source bucket gets ONE
8
+ * lifecycle update: end due snoozes, orphan items whose job was deleted,
9
+ * release claims older than 24h, and prune resolved items older than 90
10
+ * days. Job lookups are PREFETCHED before entering updates so no bucket
11
+ * queue is held across harness reads.
12
+ * Notifications and inbox-change signals fire AFTER the update, for
13
+ * reopened/orphaned items only. A failing bucket lands in `errors` and
14
+ * the sweep continues to the next bucket.
15
+ */
16
+ import { defineTask } from "@earendil-works/pi-durable";
17
+ import { changedInbox } from "./keys.js";
18
+ import { endSnooze, orphan, release } from "./lifecycle.js";
19
+ import { notifyOrphaned, notifyReopened } from "./notify.js";
20
+ import { allBuckets, bucketFor } from "./store.js";
21
+ import { transferItems } from "./transfer.js";
22
+ /** The registered task kind; the cron job's action names it. */
23
+ export const SWEEP_TASK_NAME = "escalations.sweep";
24
+ /** The 15-minute cron job the factory declares. */
25
+ export const SWEEP_JOB = {
26
+ name: "sweep",
27
+ schedule: { every: "15m" },
28
+ action: { kind: "task", task: SWEEP_TASK_NAME },
29
+ };
30
+ const DAY_MS = 86_400_000;
31
+ const CLAIM_TIMEOUT_MS = 24 * 60 * 60 * 1000;
32
+ const RESOLVED_RETENTION_MS = 90 * DAY_MS;
33
+ const CLAIM_TIMED_OUT = "claim timed out";
34
+ function jobIdOf(e) {
35
+ return e.namespace.startsWith("job:")
36
+ ? e.namespace.slice("job:".length)
37
+ : undefined;
38
+ }
39
+ /** Read-only planning pass: which jobs exist. Never holds the bucket queue. */
40
+ async function planSweep(deps, bucket) {
41
+ const items = await deps.store.list(bucket);
42
+ const jobs = new Map();
43
+ for (const item of items) {
44
+ const jobId = jobIdOf(item);
45
+ if (jobId === undefined || jobs.has(jobId))
46
+ continue;
47
+ jobs.set(jobId, await deps.host.jobs.get(jobId));
48
+ }
49
+ return { items, jobs };
50
+ }
51
+ /** Reconcile job-owned items into the buckets of their jobs' current owners. */
52
+ async function migrateTransferredJobs(deps, bucket, plan, now) {
53
+ const owner = bucket.kind === "user" ? bucket.user : undefined;
54
+ for (const [jobId, job] of plan.jobs) {
55
+ if (job === undefined || job.owner === owner)
56
+ continue;
57
+ 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);
59
+ }
60
+ }
61
+ /** One atomic lifecycle update per source bucket: the four phases in order. */
62
+ async function sweepBucket(deps, bucket, plan, now) {
63
+ const reopened = [];
64
+ const orphaned = [];
65
+ return deps.store.update(bucket, (items) => {
66
+ const next = [];
67
+ const report = { snoozesEnded: 0, orphaned: 0, claimsExpired: 0, pruned: 0 };
68
+ for (const item of items) {
69
+ // 4. Retention: drop resolved items older than 90 days.
70
+ if (item.status === "resolved" &&
71
+ item.resolvedAt !== undefined &&
72
+ item.resolvedAt <= new Date(now.getTime() - RESOLVED_RETENTION_MS).toISOString()) {
73
+ report.pruned += 1;
74
+ continue;
75
+ }
76
+ let current = item;
77
+ // 1. Snoozes due at or before now.
78
+ const unsnoozed = endSnooze(current, now);
79
+ if (unsnoozed !== current) {
80
+ current = unsnoozed;
81
+ reopened.push(current);
82
+ report.snoozesEnded += 1;
83
+ }
84
+ // 2. Orphan items whose job no longer exists.
85
+ const jobId = jobIdOf(current);
86
+ if (jobId !== undefined && plan.jobs.get(jobId) === undefined) {
87
+ const nextOrphan = orphan(current, now);
88
+ if (nextOrphan !== current) {
89
+ current = nextOrphan;
90
+ orphaned.push(current);
91
+ report.orphaned += 1;
92
+ }
93
+ }
94
+ // 3. Release claims older than 24h (orphaned claimed items stay
95
+ // claimed until this timeout — see E1 reviewer note).
96
+ if (current.status === "claimed" &&
97
+ current.claim !== undefined &&
98
+ current.claim.at <= new Date(now.getTime() - CLAIM_TIMEOUT_MS).toISOString()) {
99
+ current = release(current, current.claim.conversationId, CLAIM_TIMED_OUT, now);
100
+ report.claimsExpired += 1;
101
+ }
102
+ next.push(current);
103
+ }
104
+ return { items: next, result: { report, reopened, orphaned } };
105
+ });
106
+ }
107
+ /** The sweep body, shared by the task phase and directly unit-testable. */
108
+ export async function runSweep(deps) {
109
+ const now = deps.now?.() ?? new Date();
110
+ const report = {
111
+ snoozesEnded: 0,
112
+ orphaned: 0,
113
+ claimsExpired: 0,
114
+ pruned: 0,
115
+ errors: [],
116
+ };
117
+ for (const bucket of await allBuckets(deps.host)) {
118
+ try {
119
+ const plan = await planSweep(deps, bucket);
120
+ await migrateTransferredJobs(deps, bucket, plan, now);
121
+ const outcome = await sweepBucket(deps, bucket, plan, now);
122
+ report.snoozesEnded += outcome.report.snoozesEnded;
123
+ report.orphaned += outcome.report.orphaned;
124
+ report.claimsExpired += outcome.report.claimsExpired;
125
+ report.pruned += outcome.report.pruned;
126
+ // Notifications AFTER the update, only for reopened/orphaned items.
127
+ for (const item of outcome.reopened) {
128
+ await notifyReopened(deps.host, item);
129
+ changedInbox(deps.host, item.id);
130
+ }
131
+ for (const item of outcome.orphaned) {
132
+ await notifyOrphaned(deps.host, item);
133
+ changedInbox(deps.host, item.id);
134
+ }
135
+ }
136
+ catch (error) {
137
+ const path = deps.store.fileOf(bucket);
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ report.errors.push(`${path}: ${message}`);
140
+ console.warn(`escalations: sweep failed for ${path}`, error);
141
+ }
142
+ }
143
+ return report;
144
+ }
145
+ /** The task object the extension registers. */
146
+ export function createSweepTask(deps) {
147
+ return defineTask({
148
+ name: SWEEP_TASK_NAME,
149
+ version: 1,
150
+ initial: () => ({ phase: "sweep" }),
151
+ phases: {
152
+ sweep: async (_task, runtime, context) => {
153
+ await runSweep(deps);
154
+ // REQUIRED: a phase returning without durable progress faults the
155
+ // task. The sweep's bookkeeping lives in the store files; the
156
+ // task's own terminal outcome is the durable progress.
157
+ await runtime.commit(() => ({
158
+ status: "terminal",
159
+ outcome: { status: "completed", result: undefined },
160
+ }), context);
161
+ },
162
+ },
163
+ abort: async (_task, runtime, context) => {
164
+ await runtime.commit(() => ({
165
+ status: "terminal",
166
+ outcome: {
167
+ status: "failed",
168
+ error: { message: "aborted" },
169
+ },
170
+ }), context);
171
+ },
172
+ });
173
+ }
@@ -0,0 +1,10 @@
1
+ import { type ToolRegistration } from "@earendil-works/pi-durable";
2
+ import type { SigrunHost } from "@sigrun/sigrun/extension";
3
+ import { EscalationStore } from "./store.js";
4
+ export interface ToolDeps {
5
+ readonly host: SigrunHost;
6
+ readonly store: EscalationStore;
7
+ readonly now?: () => Date;
8
+ }
9
+ /** Build the three host-identity-derived escalation tools over one store. */
10
+ export declare function createTools(deps: ToolDeps): ToolRegistration[];