@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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +63 -2
- package/dist/dispatch.d.ts +14 -0
- package/dist/dispatch.js +165 -0
- package/dist/inbox.d.ts +31 -0
- package/dist/inbox.js +570 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +36 -0
- package/dist/json-file.d.ts +15 -0
- package/dist/json-file.js +78 -0
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +14 -0
- package/dist/lifecycle.d.ts +24 -0
- package/dist/lifecycle.js +159 -0
- package/dist/notify.d.ts +5 -0
- package/dist/notify.js +24 -0
- package/dist/section.d.ts +2 -0
- package/dist/section.js +5 -0
- package/dist/store.d.ts +22 -0
- package/dist/store.js +71 -0
- package/dist/sweep.d.ts +48 -0
- package/dist/sweep.js +173 -0
- package/dist/tools.d.ts +10 -0
- package/dist/tools.js +250 -0
- package/dist/transfer.d.ts +8 -0
- package/dist/transfer.js +41 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.js +1 -0
- package/package.json +55 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
### Added
|
|
10
|
+
- The escalation extension factory with `escalate`, `escalation_list`, and
|
|
11
|
+
`escalation_resolve` tools, prompt guidance, a four-tab Inbox, immediate
|
|
12
|
+
and next-run dispatch, and a 15-minute maintenance sweep.
|
|
13
|
+
- Durable per-user escalation records with answer, claim, snooze, dismiss,
|
|
14
|
+
restore, failure recovery, and resolved-item retention lifecycles.
|
|
15
|
+
- At-least-once escalation transfer when a directory user is deleted.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael MacLeod
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sigrun/escalations
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An asynchronous decision inbox for [sigrun](https://github.com/sigrun-ai/sigrun). Jobs and agents can ask a person to choose a course of action without pausing the work that raised the question. Escalations are durable records with a complete history; they live in their own **Inbox** pane rather than blocking a conversation.
|
|
4
|
+
|
|
5
|
+
Unlike a mid-conversation question, an escalation does not suspend its source. A scheduled run can finish and deal with the decision on a later run, and an agent can hand the answered decision to a fresh run. Both surfaces use the same choice-and-instruction vocabulary, but only a person answers, dismisses, or snoozes an escalation.
|
|
6
|
+
|
|
7
|
+
## Requirements and installation
|
|
8
|
+
|
|
9
|
+
This extension requires **sigrun core with job hooks (unreleased at the time of writing)** — the first release carrying `host.notify`, `host.jobs` and `conversations.create({job})`.
|
|
10
|
+
|
|
11
|
+
Add `@sigrun/escalations` to the `extensions` list in sigrun's settings. The extension contributes the three agent tools, the Inbox view, and a maintenance job that runs every 15 minutes.
|
|
12
|
+
|
|
13
|
+
## Agent tools
|
|
14
|
+
|
|
15
|
+
### `escalate`
|
|
16
|
+
|
|
17
|
+
Raises a decision for the conversation owner. Parameters:
|
|
18
|
+
|
|
19
|
+
- `key` (string, required): a stable identifier for the underlying issue, such as a resource key.
|
|
20
|
+
- `title` (string, required): a short summary.
|
|
21
|
+
- `body` (string, required): self-contained evidence, context, and the proposed action.
|
|
22
|
+
- `options` (array, required): one to eight choices. Each has a stable `id`, a `label`, and an optional `detail`. Do not offer “do nothing” or “later” choices; the person already has Dismiss and Snooze.
|
|
23
|
+
- `allowInstruction` (boolean, optional): whether the answer may include a free-text instruction. Defaults to `true`.
|
|
24
|
+
- `onAnswer` (`"next-run"` or `"immediate"`, optional): how to act after the answer. See below.
|
|
25
|
+
|
|
26
|
+
An unresolved item with the same key in the same job or user namespace is returned instead of duplicated. Raising a new item notifies its owner.
|
|
27
|
+
|
|
28
|
+
### `escalation_list`
|
|
29
|
+
|
|
30
|
+
Takes no parameters and returns two groups:
|
|
31
|
+
|
|
32
|
+
- **`skip`**: keys the current job must not act on because a matching escalation is open, answered, claimed, dismissed, or snoozed and is not held by this conversation. A dismissed or snoozed issue therefore stays out of the job's way until the person restores it or the snooze ends.
|
|
33
|
+
- **`act`**: answered `next-run` items whose work this conversation should carry out, including the chosen option and any instruction.
|
|
34
|
+
|
|
35
|
+
A scheduled (`cron`) run atomically claims its actionable items when it lists them. Other callers only see claims they already hold. After carrying out each claimed item, the run must resolve it.
|
|
36
|
+
|
|
37
|
+
### `escalation_resolve`
|
|
38
|
+
|
|
39
|
+
Resolves one item claimed by the current conversation:
|
|
40
|
+
|
|
41
|
+
- `id` (string, required): the escalation id.
|
|
42
|
+
- `outcome` (`"done"` or `"failed"`, required): whether the requested action succeeded.
|
|
43
|
+
- `note` (string, required): a short outcome record for the history.
|
|
44
|
+
|
|
45
|
+
`done` moves the item to **Resolved**. `failed` returns it to **Open**, clears its answer and claim, and records that the action failed. Only the claiming conversation may resolve the item.
|
|
46
|
+
|
|
47
|
+
## Next-run versus immediate dispatch
|
|
48
|
+
|
|
49
|
+
For a job conversation, the default is **`next-run`**: the answer waits until a later scheduled run, when `escalation_list` claims it for action. `next-run` is valid only for an item raised by a scheduled job.
|
|
50
|
+
|
|
51
|
+
For an ordinary agent conversation, the default is **`immediate`**: answering starts a fresh run in a **new conversation**, seeded with the escalation and the answer. It does not resume or modify the source conversation. An immediate item from a job also starts a fresh run. If the new run cannot be created or seeded, the item returns to open; if it settles without resolving its claim, the item is released back to open.
|
|
52
|
+
|
|
53
|
+
## The Inbox
|
|
54
|
+
|
|
55
|
+
The Inbox has four tabs:
|
|
56
|
+
|
|
57
|
+
- **Open**: unanswered decisions, including items returned by failed or interrupted actions.
|
|
58
|
+
- **In progress**: answered, claimed, and snoozed items.
|
|
59
|
+
- **Dismissed**: items the person has set aside. Restore moves one back to open.
|
|
60
|
+
- **Resolved**: completed items, retained for 90 days after resolution.
|
|
61
|
+
|
|
62
|
+
An open item can be answered by selecting an offered option and, when allowed, adding an instruction. **Dismiss** and **Snooze** are user-only actions, never agent options. A snooze lasts from 1 to 365 days; when it ends, the maintenance job returns the item to open. Nothing automatically answers an escalation or acts merely because a snooze expired.
|
|
63
|
+
|
|
64
|
+
The maintenance sweep also releases claims older than 24 hours, marks items whose source job was deleted, and prunes old resolved records. An orphaned item offers only Dismiss; nothing acts on it automatically.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ExtensionRunSettledHandler, SigrunHost } from "@sigrun/sigrun/extension";
|
|
2
|
+
import { EscalationStore } from "./store.js";
|
|
3
|
+
import type { Bucket, Escalation } from "./types.js";
|
|
4
|
+
export interface DispatchDeps {
|
|
5
|
+
readonly host: SigrunHost;
|
|
6
|
+
readonly store: EscalationStore;
|
|
7
|
+
readonly now?: () => Date;
|
|
8
|
+
}
|
|
9
|
+
/** Seed the fresh immediate-run conversation with the complete decision. */
|
|
10
|
+
export declare function seedText(e: Escalation, jobName?: string): string;
|
|
11
|
+
/** Create, claim, and seed one immediate run for an answered item. */
|
|
12
|
+
export declare function startImmediateRun(deps: DispatchDeps, bucket: Bucket, id: string): Promise<number | undefined>;
|
|
13
|
+
/** Release claims left unresolved by a settled run, isolating every failure. */
|
|
14
|
+
export declare function createRunSettled(deps: DispatchDeps): ExtensionRunSettledHandler;
|
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { changedInbox } from "./keys.js";
|
|
2
|
+
import { TransitionError, claim, release, reopen } from "./lifecycle.js";
|
|
3
|
+
import { notifyReopened } from "./notify.js";
|
|
4
|
+
import { bucketFor } from "./store.js";
|
|
5
|
+
import { transferItems } from "./transfer.js";
|
|
6
|
+
const RUN_ENDED = "run ended without resolving the escalation";
|
|
7
|
+
function errorMessage(error) {
|
|
8
|
+
return error instanceof Error ? error.message : String(error);
|
|
9
|
+
}
|
|
10
|
+
function sourceText(e, jobName) {
|
|
11
|
+
if (e.source.jobId !== undefined)
|
|
12
|
+
return `job "${jobName ?? e.source.jobId}" (${e.source.jobId})`;
|
|
13
|
+
if (e.source.conversationId !== undefined)
|
|
14
|
+
return `conversation #${e.source.conversationId}`;
|
|
15
|
+
return "none";
|
|
16
|
+
}
|
|
17
|
+
/** Seed the fresh immediate-run conversation with the complete decision. */
|
|
18
|
+
export function seedText(e, jobName) {
|
|
19
|
+
const option = e.options.find((candidate) => candidate.id === e.answer?.option);
|
|
20
|
+
const chosen = option === undefined
|
|
21
|
+
? "none chosen"
|
|
22
|
+
: `${option.label} (${option.id})`;
|
|
23
|
+
return `You are acting on an answered escalation (id ${e.id}).\n\n` +
|
|
24
|
+
`Title: ${e.title}\n` +
|
|
25
|
+
`Source: ${sourceText(e, jobName)}\n\n` +
|
|
26
|
+
`## Escalation\n${e.body}\n\n` +
|
|
27
|
+
`## The user's answer\n` +
|
|
28
|
+
`Chosen action: ${chosen}\n` +
|
|
29
|
+
`Instruction: ${e.answer?.instruction ?? "none"}\n\n` +
|
|
30
|
+
`Carry out the chosen action, following the instruction if one is given. When you are finished, call escalation_resolve with id "${e.id}", outcome "done" or "failed", and a short note. Do not raise a new escalation for this same issue.`;
|
|
31
|
+
}
|
|
32
|
+
async function reopenAnswer(deps, bucket, id, note) {
|
|
33
|
+
return await deps.store.update(bucket, (items) => {
|
|
34
|
+
let reopened;
|
|
35
|
+
const next = items.map((item) => {
|
|
36
|
+
if (item.id !== id || item.status !== "answered")
|
|
37
|
+
return item;
|
|
38
|
+
reopened = reopen(item, note, deps.now?.() ?? new Date());
|
|
39
|
+
return reopened;
|
|
40
|
+
});
|
|
41
|
+
return { items: reopened === undefined ? items : next, result: reopened };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async function releaseClaim(deps, bucket, id, conversationId, note) {
|
|
45
|
+
const now = deps.now?.() ?? new Date();
|
|
46
|
+
return await deps.store.update(bucket, (items) => {
|
|
47
|
+
let released;
|
|
48
|
+
const next = items.map((item) => {
|
|
49
|
+
if (item.id !== id || item.status !== "claimed" || item.claim?.conversationId !== conversationId) {
|
|
50
|
+
return item;
|
|
51
|
+
}
|
|
52
|
+
released = release(item, conversationId, note, now);
|
|
53
|
+
return released;
|
|
54
|
+
});
|
|
55
|
+
return { items: released === undefined ? items : next, result: released };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Create, claim, and seed one immediate run for an answered item. */
|
|
59
|
+
export async function startImmediateRun(deps, bucket, id) {
|
|
60
|
+
const { host, store } = deps;
|
|
61
|
+
let activeBucket = bucket;
|
|
62
|
+
let existing = (await store.list(activeBucket)).find((item) => item.id === id);
|
|
63
|
+
if (existing === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
const job = existing.source.jobId === undefined
|
|
66
|
+
? undefined
|
|
67
|
+
: await host.jobs.get(existing.source.jobId);
|
|
68
|
+
if (job !== undefined && job.owner !== existing.owner) {
|
|
69
|
+
const target = await bucketFor(host, job.owner);
|
|
70
|
+
await transferItems(store, activeBucket, target, new Set([id]), deps.now?.() ?? new Date());
|
|
71
|
+
activeBucket = target;
|
|
72
|
+
existing = (await store.list(activeBucket)).find((item) => item.id === id);
|
|
73
|
+
if (existing === undefined)
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
let conversationId;
|
|
77
|
+
try {
|
|
78
|
+
conversationId = await host.conversations.create({
|
|
79
|
+
owner: job === undefined ? existing.owner : job.owner,
|
|
80
|
+
name: `Escalation: ${existing.title}`.slice(0, 120),
|
|
81
|
+
source: "escalations",
|
|
82
|
+
...(job === undefined ? {} : { job: job.id }),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const reopened = await reopenAnswer(deps, activeBucket, id, `dispatch failed: ${errorMessage(error)}`);
|
|
87
|
+
if (reopened !== undefined) {
|
|
88
|
+
await notifyReopened(host, reopened);
|
|
89
|
+
changedInbox(host, reopened.id);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const claimed = await store.update(activeBucket, (items) => {
|
|
94
|
+
let nextClaim;
|
|
95
|
+
const next = items.map((item) => {
|
|
96
|
+
if (item.id !== id)
|
|
97
|
+
return item;
|
|
98
|
+
try {
|
|
99
|
+
nextClaim = claim(item, conversationId, deps.now?.() ?? new Date());
|
|
100
|
+
return nextClaim;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof TransitionError)
|
|
104
|
+
return item;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return { items: nextClaim === undefined ? items : next, result: nextClaim };
|
|
109
|
+
});
|
|
110
|
+
if (claimed === undefined)
|
|
111
|
+
return undefined;
|
|
112
|
+
try {
|
|
113
|
+
await host.conversations.submit(conversationId, [
|
|
114
|
+
{ type: "text", text: seedText(existing, job?.name) },
|
|
115
|
+
]);
|
|
116
|
+
return conversationId;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const released = await releaseClaim(deps, activeBucket, id, conversationId, `dispatch failed: ${errorMessage(error)}`);
|
|
120
|
+
if (released !== undefined) {
|
|
121
|
+
await notifyReopened(host, released);
|
|
122
|
+
changedInbox(host, released.id);
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Release claims left unresolved by a settled run, isolating every failure. */
|
|
128
|
+
export function createRunSettled(deps) {
|
|
129
|
+
return async (event) => {
|
|
130
|
+
try {
|
|
131
|
+
const bucket = await bucketFor(deps.host, event.owner);
|
|
132
|
+
const items = await deps.store.list(bucket);
|
|
133
|
+
if (!items.some((item) => item.status === "claimed" && item.claim?.conversationId === event.conversationId))
|
|
134
|
+
return;
|
|
135
|
+
const released = await deps.store.update(bucket, (current) => {
|
|
136
|
+
const nextReleased = [];
|
|
137
|
+
const next = current.map((item) => {
|
|
138
|
+
if (item.status !== "claimed" || item.claim?.conversationId !== event.conversationId)
|
|
139
|
+
return item;
|
|
140
|
+
const reopened = release(item, event.conversationId, RUN_ENDED, deps.now?.() ?? new Date());
|
|
141
|
+
nextReleased.push(reopened);
|
|
142
|
+
return reopened;
|
|
143
|
+
});
|
|
144
|
+
return { items: nextReleased.length === 0 ? current : next, result: nextReleased };
|
|
145
|
+
});
|
|
146
|
+
for (const item of released) {
|
|
147
|
+
try {
|
|
148
|
+
await notifyReopened(deps.host, item);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.warn(`escalations: reopen notification failed for ${item.id}`, error);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
changedInbox(deps.host, item.id);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
console.warn(`escalations: inbox change notification failed for ${item.id}`, error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
console.warn(`escalations: runSettled failed for conversation ${event.conversationId}`, error);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
package/dist/inbox.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Inbox view (Task E6): the four-tab list (Open / In progress /
|
|
3
|
+
* Dismissed / Resolved) and the per-item detail page, over the visible
|
|
4
|
+
* buckets of the CALLING principal.
|
|
5
|
+
*
|
|
6
|
+
* - List rows carry a `source` label field and the collection groups on
|
|
7
|
+
* it, so the list groups by job / conversation / agent (spec §4.5).
|
|
8
|
+
* The open tab sorts reopened items first, then newest; resolved is
|
|
9
|
+
* newest first. An answered `next-run` item whose job is disabled
|
|
10
|
+
* shows the badge `Waiting: job disabled`.
|
|
11
|
+
* - Detail mode is a page titled with the escalation title: markdown
|
|
12
|
+
* body, answer form (options in the agent's order, NONE marked
|
|
13
|
+
* recommended — the webui preselects recommended options and the
|
|
14
|
+
* proposed action may be destructive), snooze form seeded at 7 days
|
|
15
|
+
* (number fields have no `default` prop), Dismiss/Withdraw/Restore as
|
|
16
|
+
* page actions, conversation links, and the history as a log block.
|
|
17
|
+
* Orphaned items show only the pinned warn callout and Dismiss.
|
|
18
|
+
* - Every action resolves the item through `findVisible` over the
|
|
19
|
+
* caller's buckets, so another user's id behaves exactly like an
|
|
20
|
+
* unknown id (no title leak). Successful actions call `changedInbox`.
|
|
21
|
+
* - A store read failure renders a `bad` callout with the error message
|
|
22
|
+
* instead of the collection (the render never throws on corrupt data).
|
|
23
|
+
*/
|
|
24
|
+
import type { SigrunHost, SurfaceDef } from "@sigrun/sigrun/extension";
|
|
25
|
+
import { EscalationStore } from "./store.js";
|
|
26
|
+
export interface InboxDeps {
|
|
27
|
+
readonly host: SigrunHost;
|
|
28
|
+
readonly store: EscalationStore;
|
|
29
|
+
readonly now?: () => Date;
|
|
30
|
+
}
|
|
31
|
+
export declare function createInboxView(deps: InboxDeps): SurfaceDef<"view">;
|