opencode-courier 0.2.3 → 0.4.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/README.md +198 -193
- package/dist/index.d.ts +4 -8
- package/dist/index.js +66 -85
- package/dist/kernel/protocol.d.ts +20 -0
- package/dist/kernel/protocol.js +36 -0
- package/dist/kernel/tool.d.ts +5 -0
- package/dist/kernel/tool.js +5 -0
- package/dist/person/events.d.ts +23 -0
- package/dist/person/events.js +113 -0
- package/dist/person/form.d.ts +26 -0
- package/dist/person/form.js +64 -0
- package/dist/person/index.d.ts +17 -0
- package/dist/person/index.js +5 -0
- package/dist/{relay.d.ts → person/permission.d.ts} +29 -25
- package/dist/{relay.js → person/permission.js} +11 -9
- package/dist/person/ports.d.ts +58 -0
- package/dist/person/ports.js +2 -0
- package/dist/{question → person/question}/answer.d.ts +33 -4
- package/dist/{question → person/question}/answer.js +49 -17
- package/dist/person/question/index.d.ts +7 -0
- package/dist/{question → person/question}/index.js +3 -3
- package/dist/person/question/lifecycle.d.ts +43 -0
- package/dist/{question → person/question}/lifecycle.js +10 -19
- package/dist/{question → person/question}/pure.d.ts +4 -6
- package/dist/{question → person/question}/pure.js +3 -4
- package/dist/{question → person/question}/relay.d.ts +1 -1
- package/dist/{question → person/question}/relay.js +27 -35
- package/dist/{question → person/question}/runtime.d.ts +2 -3
- package/dist/{question → person/question}/runtime.js +2 -3
- package/dist/{question → person/question}/shared.d.ts +13 -3
- package/dist/person/question/shared.js +52 -0
- package/dist/person/question/types.d.ts +121 -0
- package/dist/person/question/types.js +1 -0
- package/dist/person/rpc-handlers.d.ts +160 -0
- package/dist/person/rpc-handlers.js +62 -0
- package/dist/person/rpc.d.ts +227 -0
- package/dist/person/rpc.js +70 -0
- package/dist/person/text.d.ts +68 -0
- package/dist/{notices.js → person/text.js} +53 -190
- package/dist/person/tools.d.ts +20 -0
- package/dist/person/tools.js +55 -0
- package/dist/person/tui.d.ts +46 -0
- package/dist/platform/bounded.d.ts +12 -0
- package/dist/{bounded.js → platform/bounded.js} +6 -4
- package/dist/platform/events.d.ts +34 -0
- package/dist/platform/events.js +121 -0
- package/dist/platform/hub.d.ts +195 -0
- package/dist/{hub.js → platform/hub.js} +78 -119
- package/dist/platform/json.d.ts +8 -0
- package/dist/platform/json.js +15 -0
- package/dist/platform/version.d.ts +16 -0
- package/dist/{version.js → platform/version.js} +5 -11
- package/dist/rpc.d.ts +2 -0
- package/dist/rpc.js +3 -0
- package/dist/scheduling/index.d.ts +6 -0
- package/dist/scheduling/index.js +5 -0
- package/dist/{later.d.ts → scheduling/later.d.ts} +13 -9
- package/dist/{later.js → scheduling/later.js} +18 -9
- package/dist/scheduling/text.d.ts +6 -0
- package/dist/scheduling/text.js +10 -0
- package/dist/scheduling/tools.d.ts +14 -0
- package/dist/scheduling/tools.js +48 -0
- package/dist/skill.d.ts +17 -0
- package/dist/skill.js +62 -0
- package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +5 -26
- package/dist/{cleanup.js → tree/cleanup.js} +20 -15
- package/dist/tree/context.d.ts +15 -0
- package/dist/tree/context.js +34 -0
- package/dist/tree/domain.d.ts +101 -0
- package/dist/tree/domain.js +68 -0
- package/dist/tree/events.d.ts +71 -0
- package/dist/tree/events.js +204 -0
- package/dist/tree/forget.d.ts +24 -0
- package/dist/tree/forget.js +59 -0
- package/dist/tree/group.d.ts +82 -0
- package/dist/tree/group.js +203 -0
- package/dist/tree/index.d.ts +10 -0
- package/dist/tree/index.js +11 -0
- package/dist/tree/limits.d.ts +10 -0
- package/dist/tree/limits.js +60 -0
- package/dist/tree/panel.d.ts +13 -0
- package/dist/tree/panel.js +75 -0
- package/dist/tree/ports.d.ts +276 -0
- package/dist/tree/ports.js +1 -0
- package/dist/tree/report.d.ts +23 -0
- package/dist/tree/report.js +33 -0
- package/dist/tree/roster.d.ts +27 -0
- package/dist/tree/roster.js +62 -0
- package/dist/tree/rpc-handlers.d.ts +201 -0
- package/dist/tree/rpc-handlers.js +20 -0
- package/dist/tree/rpc.d.ts +344 -0
- package/dist/tree/rpc.js +83 -0
- package/dist/tree/send.d.ts +14 -0
- package/dist/tree/send.js +99 -0
- package/dist/tree/spawn.d.ts +21 -0
- package/dist/tree/spawn.js +173 -0
- package/dist/tree/status.d.ts +128 -0
- package/dist/tree/status.js +62 -0
- package/dist/tree/stop.d.ts +33 -0
- package/dist/tree/stop.js +116 -0
- package/dist/tree/store.d.ts +24 -0
- package/dist/tree/store.js +264 -0
- package/dist/tree/subtree.d.ts +101 -0
- package/dist/tree/subtree.js +60 -0
- package/dist/tree/summary.d.ts +7 -0
- package/dist/tree/summary.js +35 -0
- package/dist/tree/text.d.ts +77 -0
- package/dist/tree/text.js +287 -0
- package/dist/tree/tools.d.ts +54 -0
- package/dist/tree/tools.js +167 -0
- package/dist/tree/tui.d.ts +53 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1219 -0
- package/dist/webhook/index.d.ts +5 -0
- package/dist/webhook/index.js +3 -0
- package/dist/{webhook.d.ts → webhook/receiver.d.ts} +25 -36
- package/dist/{webhook.js → webhook/receiver.js} +66 -67
- package/dist/webhook/subscriptions.d.ts +29 -0
- package/dist/webhook/subscriptions.js +52 -0
- package/dist/webhook/text.d.ts +23 -0
- package/dist/webhook/text.js +82 -0
- package/dist/webhook/tools.d.ts +19 -0
- package/dist/webhook/tools.js +46 -0
- package/package.json +18 -7
- package/dist/bounded.d.ts +0 -10
- package/dist/courier.d.ts +0 -95
- package/dist/courier.js +0 -131
- package/dist/hub.d.ts +0 -325
- package/dist/json.d.ts +0 -4
- package/dist/json.js +0 -4
- package/dist/notices.d.ts +0 -246
- package/dist/question/index.d.ts +0 -6
- package/dist/question/lifecycle.d.ts +0 -52
- package/dist/question/shared.js +0 -24
- package/dist/roster.d.ts +0 -81
- package/dist/roster.js +0 -175
- package/dist/tools.d.ts +0 -74
- package/dist/tools.js +0 -228
- package/dist/version.d.ts +0 -22
- package/dist/watch.d.ts +0 -61
- package/dist/watch.js +0 -289
- /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
- /package/dist/{storage.js → platform/storage.js} +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { millis, obj } from "../platform/json.js";
|
|
2
|
+
export const DEFAULT_LIMITS = { maxDepth: 3, maxChildren: 5, maxTotal: 20 };
|
|
3
|
+
/** The limits in the plugin's options; one that is not a positive integer keeps its default and is named in `problems`. */
|
|
4
|
+
export function readLimits(options) {
|
|
5
|
+
const given = obj(options);
|
|
6
|
+
const problems = [];
|
|
7
|
+
const read = (name) => {
|
|
8
|
+
const value = given[name];
|
|
9
|
+
if (value === undefined)
|
|
10
|
+
return DEFAULT_LIMITS[name];
|
|
11
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
12
|
+
return value;
|
|
13
|
+
problems.push(`${name} must be a positive integer, not ${JSON.stringify(value)}; using ${DEFAULT_LIMITS[name]}`);
|
|
14
|
+
return DEFAULT_LIMITS[name];
|
|
15
|
+
};
|
|
16
|
+
const limits = { maxDepth: read("maxDepth"), maxChildren: read("maxChildren"), maxTotal: read("maxTotal") };
|
|
17
|
+
return { limits, problems };
|
|
18
|
+
}
|
|
19
|
+
/** The `nativeChildren` option: whether a child started without isolate gets OpenCode's own `parentID`; off unless `true`. */
|
|
20
|
+
export function readNativeChildren(options) {
|
|
21
|
+
const value = obj(options).nativeChildren;
|
|
22
|
+
if (value === undefined || typeof value === "boolean")
|
|
23
|
+
return { nativeChildren: value === true, problems: [] };
|
|
24
|
+
return { nativeChildren: false, problems: [`nativeChildren must be true or false, not ${JSON.stringify(value)}; using false`] };
|
|
25
|
+
}
|
|
26
|
+
/** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
|
|
27
|
+
export const busy = (time) => time.idle === undefined || millis(time.updated) > millis(time.idle);
|
|
28
|
+
/** `lineage` over roster entries already read. */
|
|
29
|
+
export function lineageIn(entries, sessionID) {
|
|
30
|
+
return lineageBy(bySession(entries), sessionID);
|
|
31
|
+
}
|
|
32
|
+
/** Roster entries by their session, for `lineageBy`. */
|
|
33
|
+
export function bySession(entries) {
|
|
34
|
+
return new Map(entries.map((entry) => [entry.sessionID, entry]));
|
|
35
|
+
}
|
|
36
|
+
/** `lineage` over roster entries already read and mapped by `bySession`. */
|
|
37
|
+
export function lineageBy(bySession, sessionID) {
|
|
38
|
+
const chain = [];
|
|
39
|
+
for (let entry = bySession.get(sessionID); entry && !chain.includes(entry); entry = bySession.get(entry.parentID))
|
|
40
|
+
chain.push(entry);
|
|
41
|
+
return chain;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The session whose view OpenCode shows the asks of a lineage's first session in: up its native parents, to the
|
|
45
|
+
* first session that is not a native child (an isolated one, or the top). The session itself when it is not native.
|
|
46
|
+
*/
|
|
47
|
+
export function nativeRootOf(chain) {
|
|
48
|
+
let root = chain[0].sessionID;
|
|
49
|
+
for (const entry of chain) {
|
|
50
|
+
if (!entry.native)
|
|
51
|
+
break;
|
|
52
|
+
root = entry.parentID;
|
|
53
|
+
}
|
|
54
|
+
return root;
|
|
55
|
+
}
|
|
56
|
+
/** What a group may be called: short, and with no `/`, which separates the parts of its keys, or `"`, which closes an attribute. */
|
|
57
|
+
const NAME = /^[\w.-]{1,60}$/;
|
|
58
|
+
export const isGroupName = (value) => typeof value === "string" && NAME.test(value);
|
|
59
|
+
/** A member's standing from its stored value, or `released` when there is none. */
|
|
60
|
+
export function standingOf(member) {
|
|
61
|
+
if (!member)
|
|
62
|
+
return "released";
|
|
63
|
+
if (member.report)
|
|
64
|
+
return "held";
|
|
65
|
+
return member.left?.by ?? "out";
|
|
66
|
+
}
|
|
67
|
+
/** Whether every member of a group has a held report or has left: released once the parent's turn has ended. */
|
|
68
|
+
export const complete = (members) => members.every((member) => member.report || member.left);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { EventModule } from "../platform/events.js";
|
|
2
|
+
import type { ExecutionError, Waits, WatchPorts, WatchState } from "./ports.js";
|
|
3
|
+
/** The part of OpenCode's `session.execution.failed` event the notice is made from. */
|
|
4
|
+
export interface ExecutionFailed {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
/** When OpenCode published it, in epoch milliseconds. */
|
|
7
|
+
readonly created?: number;
|
|
8
|
+
readonly data: {
|
|
9
|
+
readonly sessionID: string;
|
|
10
|
+
readonly error: ExecutionError;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** OpenCode's events that concern one session's turns and inbox: `session.execution.succeeded` and the like. */
|
|
14
|
+
export interface SessionEvent {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly created?: number;
|
|
17
|
+
readonly data: {
|
|
18
|
+
readonly sessionID: string;
|
|
19
|
+
readonly reason?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
|
|
24
|
+
* call courier_send. Returns the parents told.
|
|
25
|
+
*/
|
|
26
|
+
export declare function reportFailure(ports: WatchPorts, seen: Set<string>, event: ExecutionFailed): Promise<string[]>;
|
|
27
|
+
/** OpenCode's `session.inbox.enqueued`: the item's type, which its `session.inbox.delivered` does not carry. */
|
|
28
|
+
export interface InboxEnqueued {
|
|
29
|
+
readonly data: {
|
|
30
|
+
readonly sessionID: string;
|
|
31
|
+
readonly inboxID: string;
|
|
32
|
+
readonly item: {
|
|
33
|
+
readonly type: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
|
|
38
|
+
export declare function noteEnqueued(ports: WatchPorts, inbox: Map<string, string>, event: InboxEnqueued): Promise<boolean>;
|
|
39
|
+
/**
|
|
40
|
+
* Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
|
|
41
|
+
* `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
|
|
42
|
+
*/
|
|
43
|
+
export declare function notePrompt(ports: WatchPorts, event: SessionEvent & {
|
|
44
|
+
readonly data: {
|
|
45
|
+
readonly inboxID?: string;
|
|
46
|
+
};
|
|
47
|
+
}, inbox?: Map<string, string>): Promise<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* Notes that a spawned session's turn was stopped, which takes it out of its group without a report; one
|
|
50
|
+
* stopped by a shutdown resumes on the next start.
|
|
51
|
+
*/
|
|
52
|
+
export declare function noteInterrupted(ports: WatchPorts, event: SessionEvent): Promise<boolean>;
|
|
53
|
+
/**
|
|
54
|
+
* Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
|
|
55
|
+
* started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
|
|
56
|
+
*/
|
|
57
|
+
export declare function noteDeleted(ports: WatchPorts, event: SessionEvent): Promise<string[]>;
|
|
58
|
+
/** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
|
|
59
|
+
export declare const waitsOn: (...suppliers: Waits[]) => Waits;
|
|
60
|
+
/** A `Waits` asking `suppliers` at once, as cheap as each other: it waits when one does, and fails when one fails. */
|
|
61
|
+
export declare const together: (...suppliers: Waits[]) => Waits;
|
|
62
|
+
/**
|
|
63
|
+
* Tells the parent of a spawned session whose turn ended without reporting to it since its last
|
|
64
|
+
* prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
|
|
65
|
+
*/
|
|
66
|
+
export declare function reportSilent(ports: WatchPorts, seen: Set<string>, event: SessionEvent, outside: Waits): Promise<string[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Tells parents of a child's failed or silent turn, unless it waits on what `outside` says, and notes
|
|
69
|
+
* its prompts, interruptions and deletion; `after` a turn's end, seals the groups of its session.
|
|
70
|
+
*/
|
|
71
|
+
export declare function treeEvents(state: WatchState, outside: Waits): EventModule<WatchPorts>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { envelope } from "../kernel/protocol.js";
|
|
2
|
+
import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
|
|
3
|
+
import { forgetDeleted } from "./forget.js";
|
|
4
|
+
import { hasCompleteGroup, leave, outIn } from "./group.js";
|
|
5
|
+
import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
|
|
6
|
+
import { childStore, groupStore } from "./store.js";
|
|
7
|
+
import { lastReply } from "./status.js";
|
|
8
|
+
import { failureNotice, silentNotice } from "./text.js";
|
|
9
|
+
/**
|
|
10
|
+
* Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
|
|
11
|
+
* call courier_send. Returns the parents told.
|
|
12
|
+
*/
|
|
13
|
+
export async function reportFailure(ports, seen, event) {
|
|
14
|
+
if (!claim(seen, event.id))
|
|
15
|
+
return [];
|
|
16
|
+
const { sessionID, error } = event.data;
|
|
17
|
+
const children = childStore(ports.storage);
|
|
18
|
+
const entries = await children.entriesOf(sessionID);
|
|
19
|
+
const at = event.created ?? ports.now();
|
|
20
|
+
// Not in the way of the notice: kept only so the limits stop counting the child.
|
|
21
|
+
if (entries.length)
|
|
22
|
+
await children.settled(sessionID, "failed", at).catch((error) => ports.log(`courier watch: could not note the failed turn of ${sessionID}: ${String(error)}`));
|
|
23
|
+
const out = await Promise.all(entries.map((entry) => outIn(ports, entry)));
|
|
24
|
+
// Left before the notice, so the parent waits on the group meanwhile; nudged after it, so the group's
|
|
25
|
+
// message does not overtake the notice, short of an interval tick falling in between.
|
|
26
|
+
const completed = await Promise.all(out.map((member) => (member ? leave(ports, member, "failed", at) : Promise.resolve(false))));
|
|
27
|
+
// A session courier_stop stopped is not a failure to tell: its parent was told of the stop, or ordered it.
|
|
28
|
+
const quiet = entries.length > 0 && (await stoppedSincePrompt(children, sessionID).catch(() => false));
|
|
29
|
+
try {
|
|
30
|
+
if (quiet)
|
|
31
|
+
return [];
|
|
32
|
+
await Promise.all(entries.map((entry, index) => ports.session.synthetic({
|
|
33
|
+
sessionID: entry.parentID,
|
|
34
|
+
text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group), { failed: error.type }),
|
|
35
|
+
description: `Session ${sessionID} failed`,
|
|
36
|
+
metadata: { source: "courier", from: sessionID, failed: true },
|
|
37
|
+
delivery: "steer",
|
|
38
|
+
})));
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
if (completed.includes(true))
|
|
42
|
+
ports.nudge();
|
|
43
|
+
}
|
|
44
|
+
return entries.map((entry) => entry.parentID);
|
|
45
|
+
}
|
|
46
|
+
/** Inbox items that are not courier's or the parent's business: the person's own prompts, compactions and moves. */
|
|
47
|
+
const UNOWED = new Set(["user", "compaction", "move"]);
|
|
48
|
+
/** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
|
|
49
|
+
export async function noteEnqueued(ports, inbox, event) {
|
|
50
|
+
const { sessionID, inboxID, item } = event.data;
|
|
51
|
+
if ((await childStore(ports.storage).indexedParent(sessionID)) === undefined)
|
|
52
|
+
return false;
|
|
53
|
+
setBounded(inbox, inboxID, item.type, SEEN_MAX);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
|
|
58
|
+
* `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
|
|
59
|
+
*/
|
|
60
|
+
export async function notePrompt(ports, event, inbox) {
|
|
61
|
+
const { sessionID, inboxID } = event.data;
|
|
62
|
+
// Kept, not deleted: the hub's other subscription looks it up for the same delivery.
|
|
63
|
+
if (inboxID !== undefined && UNOWED.has(inbox?.get(inboxID) ?? ""))
|
|
64
|
+
return false;
|
|
65
|
+
const children = childStore(ports.storage);
|
|
66
|
+
if ((await children.indexedParent(sessionID)) === undefined)
|
|
67
|
+
return false;
|
|
68
|
+
await children.prompted(sessionID, event.created ?? ports.now());
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Notes that a spawned session's turn was stopped, which takes it out of its group without a report; one
|
|
73
|
+
* stopped by a shutdown resumes on the next start.
|
|
74
|
+
*/
|
|
75
|
+
export async function noteInterrupted(ports, event) {
|
|
76
|
+
if (event.data.reason === "shutdown")
|
|
77
|
+
return false;
|
|
78
|
+
const { sessionID } = event.data;
|
|
79
|
+
const children = childStore(ports.storage);
|
|
80
|
+
const entry = await children.indexedEntry(sessionID);
|
|
81
|
+
if (entry === undefined)
|
|
82
|
+
return false;
|
|
83
|
+
const at = event.created ?? ports.now();
|
|
84
|
+
await children.settled(sessionID, "interrupted", at);
|
|
85
|
+
const member = await outIn(ports, entry);
|
|
86
|
+
if (member && (await leave(ports, member, "interrupted", at)))
|
|
87
|
+
ports.nudge();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
|
|
92
|
+
* started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
|
|
93
|
+
*/
|
|
94
|
+
export async function noteDeleted(ports, event) {
|
|
95
|
+
const { sessionID } = event.data;
|
|
96
|
+
const children = childStore(ports.storage);
|
|
97
|
+
const [parentID, started] = await Promise.all([children.indexedParent(sessionID), children.children(sessionID)]);
|
|
98
|
+
const forgotten = [...(parentID === undefined ? [] : [sessionID]), ...started.map((child) => child.sessionID)];
|
|
99
|
+
const entry = parentID === undefined ? undefined : await children.entry(parentID, sessionID);
|
|
100
|
+
const member = entry && (await outIn(ports, entry));
|
|
101
|
+
await forgetDeleted(ports, sessionID, forgotten, member, event.created);
|
|
102
|
+
return forgotten;
|
|
103
|
+
}
|
|
104
|
+
/** A `Waits` asking `suppliers` one after another, cheapest first: the first that waits answers. */
|
|
105
|
+
export const waitsOn = (...suppliers) => async (ports, sessionID, since) => {
|
|
106
|
+
for (const supplier of suppliers)
|
|
107
|
+
if (await supplier(ports, sessionID, since))
|
|
108
|
+
return true;
|
|
109
|
+
return false;
|
|
110
|
+
};
|
|
111
|
+
/** A `Waits` asking `suppliers` at once, as cheap as each other: it waits when one does, and fails when one fails. */
|
|
112
|
+
export const together = (...suppliers) => async (ports, sessionID, since) => (await Promise.all(suppliers.map((supplier) => supplier(ports, sessionID, since)))).includes(true);
|
|
113
|
+
/**
|
|
114
|
+
* Whether a session prompted at `prompt` waits: on an untold report of a session it started, a complete group of
|
|
115
|
+
* its about to be delivered, or what `outside` says it waits on. Cheapest first.
|
|
116
|
+
*/
|
|
117
|
+
async function waits(ports, outside, sessionID, prompt) {
|
|
118
|
+
const children = childStore(ports.storage);
|
|
119
|
+
const started = await children.children(sessionID);
|
|
120
|
+
if ((await Promise.all(started.map((entry) => awaited(children, entry.sessionID)))).includes(true))
|
|
121
|
+
return true;
|
|
122
|
+
// A group with a member still out is waited on through that member, above; one whose every out member was told of is not.
|
|
123
|
+
if (started.some((entry) => entry.group) && (await hasCompleteGroup(groupStore(ports.storage), sessionID)))
|
|
124
|
+
return true;
|
|
125
|
+
return outside(ports, sessionID, prompt);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Tells the parent of a spawned session whose turn ended without reporting to it since its last
|
|
129
|
+
* prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
|
|
130
|
+
*/
|
|
131
|
+
export async function reportSilent(ports, seen, event, outside) {
|
|
132
|
+
const { sessionID } = event.data;
|
|
133
|
+
const children = childStore(ports.storage);
|
|
134
|
+
const entry = await children.indexedEntry(sessionID);
|
|
135
|
+
// Claimed after the lookup, so other sessions' turns do not crowd `seen`, and before anything is told.
|
|
136
|
+
if (!entry || !claim(seen, event.id))
|
|
137
|
+
return [];
|
|
138
|
+
const report = await reportOf(children, sessionID);
|
|
139
|
+
if (!report?.owes || (await waits(ports, outside, sessionID, report.prompt)))
|
|
140
|
+
return [];
|
|
141
|
+
if (await stoppedSincePrompt(children, sessionID).catch(() => false))
|
|
142
|
+
return [];
|
|
143
|
+
// The notice goes out without the reply rather than not at all.
|
|
144
|
+
const lastText = await ports.session.context({ sessionID }).then(lastReply, () => undefined);
|
|
145
|
+
const group = (await outIn(ports, entry))?.group;
|
|
146
|
+
// Before the notice, which may end the parent's turn at once: once told, it no longer waits for this one.
|
|
147
|
+
const toldAt = event.created ?? ports.now();
|
|
148
|
+
await children.told(sessionID, toldAt).catch((error) => ports.log(`courier watch: could not note that the parent of ${sessionID} was told: ${String(error)}`));
|
|
149
|
+
try {
|
|
150
|
+
await ports.session.synthetic({
|
|
151
|
+
sessionID: entry.parentID,
|
|
152
|
+
text: envelope(sessionID, silentNotice(entry.title, lastText, report.progressed, group), { ended: "without-report" }),
|
|
153
|
+
description: `Session ${sessionID} ended without a report`,
|
|
154
|
+
metadata: { source: "courier", from: sessionID, ended: "without-report" },
|
|
155
|
+
delivery: "steer",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
// Not told after all: the parent still waits for it.
|
|
160
|
+
await children.untold(sessionID, toldAt).catch(() => undefined);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return [entry.parentID];
|
|
164
|
+
}
|
|
165
|
+
const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
|
|
166
|
+
/** A session's turn ended, sealing its groups: one that is complete has the scheduler deliver it now. */
|
|
167
|
+
async function sealGroups(ports, event) {
|
|
168
|
+
if (event.data.reason === "shutdown")
|
|
169
|
+
return;
|
|
170
|
+
try {
|
|
171
|
+
if (await hasCompleteGroup(groupStore(ports.storage), event.data.sessionID))
|
|
172
|
+
ports.nudge();
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
ports.log(`courier watch: could not read the groups of ${event.data.sessionID}: ${String(error)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** The events of spawned sessions' turns and inboxes, and their deletion: the tree's. */
|
|
179
|
+
function routeTree(ports, state, outside, event) {
|
|
180
|
+
if (event.type === "session.execution.failed")
|
|
181
|
+
return reportFailure(ports, state.seen, event);
|
|
182
|
+
if (event.type === "session.execution.succeeded")
|
|
183
|
+
return reportSilent(ports, state.seen, event, outside);
|
|
184
|
+
if (event.type === "session.execution.interrupted")
|
|
185
|
+
return noteInterrupted(ports, event);
|
|
186
|
+
if (event.type === "session.inbox.enqueued")
|
|
187
|
+
return noteEnqueued(ports, state.inbox, event);
|
|
188
|
+
if (event.type === "session.inbox.delivered")
|
|
189
|
+
return notePrompt(ports, event, state.inbox);
|
|
190
|
+
if (event.type === "session.deleted")
|
|
191
|
+
return noteDeleted(ports, event);
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Tells parents of a child's failed or silent turn, unless it waits on what `outside` says, and notes
|
|
196
|
+
* its prompts, interruptions and deletion; `after` a turn's end, seals the groups of its session.
|
|
197
|
+
*/
|
|
198
|
+
export function treeEvents(state, outside) {
|
|
199
|
+
return {
|
|
200
|
+
handle: (ports, event) => routeTree(ports, state, outside, event),
|
|
201
|
+
// After: a silent end is judged while the group the nudge may deliver is still there to wait on.
|
|
202
|
+
after: (ports, event) => (TURN_ENDS.has(event.type) ? sealGroups(ports, event) : undefined),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CleanupPorts, GroupStore, Membership, ReportKeys, RosterEntry, RosterStorage, WatchPorts } from "./ports.js";
|
|
2
|
+
/** Drops a member from its group as its roster entry goes, unless its report is held: that still goes to the parent. True when dropped. */
|
|
3
|
+
export declare function dropMember(groups: GroupStore, parentID: string, group: string, sessionID: string): Promise<boolean>;
|
|
4
|
+
/** Drops every group of a parent, whose members' reports can no longer be delivered. */
|
|
5
|
+
export declare function dropGroups(groups: GroupStore, parentID: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Forgets a child: its roster entry, its reverse key, its report state and, given its group, its membership. Only
|
|
8
|
+
* the entry's removal can fail it: keys left behind lead nowhere, and the next load drops them.
|
|
9
|
+
*/
|
|
10
|
+
export declare function forgetChild(storage: RosterStorage, parentID: string, sessionID: string, group?: string): Promise<{
|
|
11
|
+
droppedFromGroup: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
/**
|
|
14
|
+
* Drops a member still out from its group as `dropped`, so the group is released without it, then the roster entry
|
|
15
|
+
* and report state. A held report stays with its group. Returns the group it was dropped from.
|
|
16
|
+
*/
|
|
17
|
+
export declare function forgetDropped(ports: Pick<CleanupPorts, "storage" | "now" | "nudge">, entry: RosterEntry): Promise<string | undefined>;
|
|
18
|
+
/**
|
|
19
|
+
* Forgets what a session OpenCode deleted leaves: the report state of `forgotten`, itself and the sessions it started,
|
|
20
|
+
* and its groups; `member`, its place in its own group, it leaves at `created`, or now.
|
|
21
|
+
*/
|
|
22
|
+
export declare function forgetDeleted(ports: WatchPorts, sessionID: string, forgotten: ReadonlyArray<string>, member: Membership | undefined, created?: number): Promise<void>;
|
|
23
|
+
/** Removes report keys of sessions with no roster entry; a key that will not go is left for the next load. */
|
|
24
|
+
export declare function forgetReports(storage: RosterStorage, keys: ReportKeys, sessions: ReadonlyArray<string>): Promise<void>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { leave, markLeft, membershipFor } from "./group.js";
|
|
2
|
+
import { childStore, groupStore } from "./store.js";
|
|
3
|
+
// Forgetting a child and what it leaves behind. Its roster entry and report state go together; its group is another
|
|
4
|
+
// aggregate, left best effort and alongside, so a report held there outlives the entry and still reaches the parent.
|
|
5
|
+
/** Drops a member from its group as its roster entry goes, unless its report is held: that still goes to the parent. True when dropped. */
|
|
6
|
+
export async function dropMember(groups, parentID, group, sessionID) {
|
|
7
|
+
const member = await groups.member(parentID, group, sessionID);
|
|
8
|
+
if (!member || member.report)
|
|
9
|
+
return false;
|
|
10
|
+
await groups.remove(parentID, group, sessionID);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
/** Drops every group of a parent, whose members' reports can no longer be delivered. */
|
|
14
|
+
export async function dropGroups(groups, parentID) {
|
|
15
|
+
const members = await groups.ofParent(parentID);
|
|
16
|
+
await Promise.all(members.map((member) => groups.remove(parentID, member.group, member.sessionID)));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Forgets a child: its roster entry, its reverse key, its report state and, given its group, its membership. Only
|
|
20
|
+
* the entry's removal can fail it: keys left behind lead nowhere, and the next load drops them.
|
|
21
|
+
*/
|
|
22
|
+
export async function forgetChild(storage, parentID, sessionID, group) {
|
|
23
|
+
const [, dropped] = await Promise.all([
|
|
24
|
+
childStore(storage).remove(parentID, sessionID),
|
|
25
|
+
group ? dropMember(groupStore(storage), parentID, group, sessionID).catch(() => false) : false,
|
|
26
|
+
]);
|
|
27
|
+
return { droppedFromGroup: dropped };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Drops a member still out from its group as `dropped`, so the group is released without it, then the roster entry
|
|
31
|
+
* and report state. A held report stays with its group. Returns the group it was dropped from.
|
|
32
|
+
*/
|
|
33
|
+
export async function forgetDropped(ports, entry) {
|
|
34
|
+
const groups = groupStore(ports.storage);
|
|
35
|
+
const member = await membershipFor(groups, entry);
|
|
36
|
+
if (member?.out && (await markLeft(groups, member, "dropped", ports.now())))
|
|
37
|
+
ports.nudge();
|
|
38
|
+
await forgetChild(ports.storage, entry.parentID, entry.sessionID);
|
|
39
|
+
return member?.out ? member.group : undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Forgets what a session OpenCode deleted leaves: the report state of `forgotten`, itself and the sessions it started,
|
|
43
|
+
* and its groups; `member`, its place in its own group, it leaves at `created`, or now.
|
|
44
|
+
*/
|
|
45
|
+
export async function forgetDeleted(ports, sessionID, forgotten, member, created) {
|
|
46
|
+
const children = childStore(ports.storage);
|
|
47
|
+
// Its groups go whether or not it has children on the roster: a held report outlives the member's entry.
|
|
48
|
+
const [completed] = await Promise.all([
|
|
49
|
+
member ? leave(ports, member, "deleted", created ?? ports.now()) : Promise.resolve(false),
|
|
50
|
+
...forgotten.map((id) => children.forgetReport(id)),
|
|
51
|
+
dropGroups(groupStore(ports.storage), sessionID),
|
|
52
|
+
]);
|
|
53
|
+
if (completed)
|
|
54
|
+
ports.nudge();
|
|
55
|
+
}
|
|
56
|
+
/** Removes report keys of sessions with no roster entry; a key that will not go is left for the next load. */
|
|
57
|
+
export async function forgetReports(storage, keys, sessions) {
|
|
58
|
+
await childStore(storage).dropReported(sessions.flatMap((sessionID) => keys.get(sessionID) ?? []));
|
|
59
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type HeldReport, type LeftMember, type RosterEntry } from "./domain.js";
|
|
2
|
+
import type { GroupPorts, GroupStore, Membership, WatchPorts } from "./ports.js";
|
|
3
|
+
/** Makes a spawned session a member of its parent's group, opening the group if it was not. */
|
|
4
|
+
export declare function joinGroup(groups: GroupStore, parentID: string, group: string, sessionID: string, title: string, at: number): Promise<void>;
|
|
5
|
+
/** Whether a parent has a complete group with a report in it, which the scheduler delivers once the parent's turn has ended. */
|
|
6
|
+
export declare function hasCompleteGroup(groups: GroupStore, parentID: string): Promise<boolean>;
|
|
7
|
+
/** Holds a member's report with its group, in place of an earlier one, or of how it had left. */
|
|
8
|
+
export declare function holdReport(groups: GroupStore, membership: Membership, report: HeldReport): Promise<void>;
|
|
9
|
+
/** How many members of a group have reported and how many have not left, and whether the group is complete. */
|
|
10
|
+
export declare function groupStanding(groups: GroupStore, parentID: string, group: string): Promise<{
|
|
11
|
+
reported: number;
|
|
12
|
+
members: number;
|
|
13
|
+
complete: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
/** `groupStanding` of each open group of a parent, by name, from one read of its members. */
|
|
16
|
+
export declare function groupStandings(groups: Pick<GroupStore, "ofParent">, parentID: string): Promise<{
|
|
17
|
+
reported: number;
|
|
18
|
+
members: number;
|
|
19
|
+
complete: boolean;
|
|
20
|
+
name: string;
|
|
21
|
+
}[]>;
|
|
22
|
+
/** A session's membership of a parent's group, if it is a member; `out` says whether the group still waits for its report. */
|
|
23
|
+
export declare function membershipOf(groups: Pick<GroupStore, "member">, parentID: string, group: string, sessionID: string): Promise<{
|
|
24
|
+
parentID: string;
|
|
25
|
+
group: string;
|
|
26
|
+
sessionID: string;
|
|
27
|
+
out: boolean;
|
|
28
|
+
title: string;
|
|
29
|
+
joinedAt: number;
|
|
30
|
+
report?: HeldReport;
|
|
31
|
+
left?: {
|
|
32
|
+
readonly at: number;
|
|
33
|
+
readonly by: LeftMember["by"];
|
|
34
|
+
};
|
|
35
|
+
} | undefined>;
|
|
36
|
+
/** A child's membership of the group its roster entry names; none without one. A failed read is the caller's to handle. */
|
|
37
|
+
export declare function membershipFor(groups: Pick<GroupStore, "member">, entry: RosterEntry): Promise<{
|
|
38
|
+
parentID: string;
|
|
39
|
+
group: string;
|
|
40
|
+
sessionID: string;
|
|
41
|
+
out: boolean;
|
|
42
|
+
title: string;
|
|
43
|
+
joinedAt: number;
|
|
44
|
+
report?: HeldReport;
|
|
45
|
+
left?: {
|
|
46
|
+
readonly at: number;
|
|
47
|
+
readonly by: LeftMember["by"];
|
|
48
|
+
};
|
|
49
|
+
} | undefined>;
|
|
50
|
+
/**
|
|
51
|
+
* Notes that a member left its group without a report: its turn failed or was interrupted, it was deleted or dropped. Read
|
|
52
|
+
* again first: one whose report is held meanwhile keeps it, one that left keeps why. True when that completes the group.
|
|
53
|
+
*/
|
|
54
|
+
export declare function markLeft(groups: GroupStore, membership: Membership, by: LeftMember["by"], at: number): Promise<boolean>;
|
|
55
|
+
/** `markLeft` for a member not read yet; undefined when it is in no open group. */
|
|
56
|
+
export declare function leaveGroup(groups: GroupStore, parentID: string, group: string, sessionID: string, by: LeftMember["by"], at: number): Promise<boolean | undefined>;
|
|
57
|
+
/** A child's membership of an open group it is still out in: the group waits for it, and its end takes it out without a report. */
|
|
58
|
+
export declare function outIn(ports: Pick<WatchPorts, "storage" | "log">, entry: RosterEntry): Promise<{
|
|
59
|
+
parentID: string;
|
|
60
|
+
group: string;
|
|
61
|
+
sessionID: string;
|
|
62
|
+
out: boolean;
|
|
63
|
+
title: string;
|
|
64
|
+
joinedAt: number;
|
|
65
|
+
report?: HeldReport;
|
|
66
|
+
left?: {
|
|
67
|
+
readonly at: number;
|
|
68
|
+
readonly by: LeftMember["by"];
|
|
69
|
+
};
|
|
70
|
+
} | undefined>;
|
|
71
|
+
/** Takes a member out of its group without a report; true when that completes the group, for the caller to nudge the scheduler. */
|
|
72
|
+
export declare function leave(ports: Pick<WatchPorts, "storage" | "log">, member: Membership, by: "failed" | "interrupted" | "deleted", at: number): Promise<boolean>;
|
|
73
|
+
/**
|
|
74
|
+
* Puts a member whose report is held back among those out: its latest word to the parent was a blocked report. Read
|
|
75
|
+
* again first: a group released meanwhile is not opened again, and a report held since is kept.
|
|
76
|
+
*/
|
|
77
|
+
export declare function unholdForBlocked(groups: GroupStore, membership: Membership): Promise<void>;
|
|
78
|
+
/**
|
|
79
|
+
* Delivers every released group once, from the scheduler's tick: claimed in the shared `claimed`, re-read,
|
|
80
|
+
* delivered and only then dropped, so a failed delivery is tried again at the next tick, and a crash in between delivers it again.
|
|
81
|
+
*/
|
|
82
|
+
export declare function deliverReleased(ports: GroupPorts, claimed: Set<string>): Promise<void>;
|