opencode-courier 0.3.0 → 0.4.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/README.md +180 -274
- package/dist/index.js +47 -73
- 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} +27 -21
- package/dist/{relay.js → person/permission.js} +9 -5
- 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 +43 -8
- package/dist/person/question/index.d.ts +7 -0
- package/dist/{question → person/question}/index.js +3 -3
- package/dist/{question → person/question}/lifecycle.d.ts +1 -1
- package/dist/{question → person/question}/pure.d.ts +1 -2
- package/dist/{question → person/question}/pure.js +1 -1
- package/dist/{question → person/question}/relay.d.ts +1 -1
- package/dist/{question → person/question}/relay.js +14 -8
- 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/person/text.js +262 -0
- 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/{bounded.d.ts → platform/bounded.d.ts} +4 -0
- package/dist/{bounded.js → platform/bounded.js} +4 -0
- 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} +27 -71
- package/dist/{version.js → platform/version.js} +1 -1
- 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} +7 -3
- package/dist/{later.js → scheduling/later.js} +12 -4
- 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 +4 -0
- package/dist/skill.js +54 -1
- package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +1 -27
- package/dist/{cleanup.js → tree/cleanup.js} +9 -16
- 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/{watch.d.ts → tree/events.d.ts} +10 -37
- 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/{limits.js → tree/limits.js} +6 -55
- 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/{stop.d.ts → tree/stop.d.ts} +1 -9
- package/dist/{stop.js → tree/stop.js} +9 -13
- package/dist/tree/store.d.ts +24 -0
- package/dist/tree/store.js +264 -0
- package/dist/{tree.d.ts → tree/subtree.d.ts} +5 -6
- package/dist/{tree.js → tree/subtree.js} +7 -5
- 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/{tools.js → tree/tools.js} +12 -142
- package/dist/tree/tui.d.ts +53 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1342 -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} +17 -23
- package/dist/{webhook.js → webhook/receiver.js} +58 -50
- 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/courier.d.ts +0 -200
- package/dist/courier.js +0 -315
- package/dist/group.d.ts +0 -86
- package/dist/group.js +0 -247
- package/dist/hub.d.ts +0 -300
- package/dist/limits.d.ts +0 -65
- package/dist/notices.d.ts +0 -356
- package/dist/notices.js +0 -678
- package/dist/question/index.d.ts +0 -6
- package/dist/question/shared.js +0 -24
- package/dist/report.d.ts +0 -48
- package/dist/report.js +0 -88
- package/dist/roster.d.ts +0 -93
- package/dist/roster.js +0 -206
- package/dist/tools.d.ts +0 -94
- package/dist/watch.js +0 -474
- /package/dist/{question → person/question}/lifecycle.js +0 -0
- /package/dist/{question → person/question}/runtime.d.ts +0 -0
- /package/dist/{question → person/question}/runtime.js +0 -0
- /package/dist/{json.d.ts → platform/json.d.ts} +0 -0
- /package/dist/{json.js → platform/json.js} +0 -0
- /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
- /package/dist/{storage.js → platform/storage.js} +0 -0
- /package/dist/{version.d.ts → platform/version.d.ts} +0 -0
|
@@ -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>;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { isNotFound, millis } from "../platform/json.js";
|
|
2
|
+
import { envelope } from "../kernel/protocol.js";
|
|
3
|
+
import { complete, standingOf } from "./domain.js";
|
|
4
|
+
import { groupStore } from "./store.js";
|
|
5
|
+
import { groupNotice } from "./text.js";
|
|
6
|
+
/** Makes a spawned session a member of its parent's group, opening the group if it was not. */
|
|
7
|
+
export async function joinGroup(groups, parentID, group, sessionID, title, at) {
|
|
8
|
+
await groups.put(parentID, group, sessionID, { title, joinedAt: at });
|
|
9
|
+
}
|
|
10
|
+
/** The members of each group among `members`, by the claim key of the group. */
|
|
11
|
+
function byGroup(members) {
|
|
12
|
+
const groups = new Map();
|
|
13
|
+
for (const member of members) {
|
|
14
|
+
const key = claimKey(member.parentID, member.group);
|
|
15
|
+
const group = groups.get(key);
|
|
16
|
+
if (group)
|
|
17
|
+
group.push(member);
|
|
18
|
+
else
|
|
19
|
+
groups.set(key, [member]);
|
|
20
|
+
}
|
|
21
|
+
return groups;
|
|
22
|
+
}
|
|
23
|
+
/** Whether a parent has a complete group with a report in it, which the scheduler delivers once the parent's turn has ended. */
|
|
24
|
+
export async function hasCompleteGroup(groups, parentID) {
|
|
25
|
+
return [...byGroup(await groups.ofParent(parentID)).values()].some((members) => complete(members) && members.some((member) => member.report));
|
|
26
|
+
}
|
|
27
|
+
/** Holds a member's report with its group, in place of an earlier one, or of how it had left. */
|
|
28
|
+
export async function holdReport(groups, membership, report) {
|
|
29
|
+
const { parentID, group, sessionID, title, joinedAt } = membership;
|
|
30
|
+
await groups.put(parentID, group, sessionID, { title, joinedAt, report });
|
|
31
|
+
}
|
|
32
|
+
/** How many of these members of a group have reported and how many have not left, and whether the group is complete. */
|
|
33
|
+
const tally = (members) => ({
|
|
34
|
+
reported: members.filter((member) => member.report).length,
|
|
35
|
+
members: members.filter((member) => !member.left).length,
|
|
36
|
+
complete: complete(members),
|
|
37
|
+
});
|
|
38
|
+
/** How many members of a group have reported and how many have not left, and whether the group is complete. */
|
|
39
|
+
export async function groupStanding(groups, parentID, group) {
|
|
40
|
+
return tally(await groups.ofGroup(parentID, group));
|
|
41
|
+
}
|
|
42
|
+
/** `groupStanding` of each open group of a parent, by name, from one read of its members. */
|
|
43
|
+
export async function groupStandings(groups, parentID) {
|
|
44
|
+
const byName = new Map();
|
|
45
|
+
for (const member of await groups.ofParent(parentID))
|
|
46
|
+
byName.set(member.group, [...(byName.get(member.group) ?? []), member]);
|
|
47
|
+
return [...byName].map(([name, members]) => ({ name, ...tally(members) }));
|
|
48
|
+
}
|
|
49
|
+
/** A session's membership of a parent's group, if it is a member; `out` says whether the group still waits for its report. */
|
|
50
|
+
export async function membershipOf(groups, parentID, group, sessionID) {
|
|
51
|
+
const member = await groups.member(parentID, group, sessionID);
|
|
52
|
+
return member && { ...member, parentID, group, sessionID, out: standingOf(member) === "out" };
|
|
53
|
+
}
|
|
54
|
+
/** A child's membership of the group its roster entry names; none without one. A failed read is the caller's to handle. */
|
|
55
|
+
export async function membershipFor(groups, entry) {
|
|
56
|
+
return entry.group ? membershipOf(groups, entry.parentID, entry.group, entry.sessionID) : undefined;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Notes that a member left its group without a report: its turn failed or was interrupted, it was deleted or dropped. Read
|
|
60
|
+
* again first: one whose report is held meanwhile keeps it, one that left keeps why. True when that completes the group.
|
|
61
|
+
*/
|
|
62
|
+
export async function markLeft(groups, membership, by, at) {
|
|
63
|
+
const { parentID, group, sessionID } = membership;
|
|
64
|
+
const current = await groups.member(parentID, group, sessionID);
|
|
65
|
+
if (!current || standingOf(current) !== "out")
|
|
66
|
+
return false;
|
|
67
|
+
await groups.put(parentID, group, sessionID, { title: current.title, joinedAt: current.joinedAt, left: { at, by } });
|
|
68
|
+
return (await groupStanding(groups, parentID, group)).complete;
|
|
69
|
+
}
|
|
70
|
+
/** `markLeft` for a member not read yet; undefined when it is in no open group. */
|
|
71
|
+
export async function leaveGroup(groups, parentID, group, sessionID, by, at) {
|
|
72
|
+
const membership = await membershipOf(groups, parentID, group, sessionID);
|
|
73
|
+
return membership && (await markLeft(groups, membership, by, at));
|
|
74
|
+
}
|
|
75
|
+
/** 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. */
|
|
76
|
+
export async function outIn(ports, entry) {
|
|
77
|
+
try {
|
|
78
|
+
const member = await membershipFor(groupStore(ports.storage), entry);
|
|
79
|
+
return member?.out ? member : undefined;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
ports.log(`courier watch: could not read ${entry.sessionID}'s place in group ${entry.group}: ${String(error)}`);
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Takes a member out of its group without a report; true when that completes the group, for the caller to nudge the scheduler. */
|
|
87
|
+
export async function leave(ports, member, by, at) {
|
|
88
|
+
try {
|
|
89
|
+
return await markLeft(groupStore(ports.storage), member, by, at);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
ports.log(`courier watch: could not take ${member.sessionID} out of group ${member.group}: ${String(error)}`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Puts a member whose report is held back among those out: its latest word to the parent was a blocked report. Read
|
|
98
|
+
* again first: a group released meanwhile is not opened again, and a report held since is kept.
|
|
99
|
+
*/
|
|
100
|
+
export async function unholdForBlocked(groups, membership) {
|
|
101
|
+
const { parentID, group, sessionID, title, joinedAt } = membership;
|
|
102
|
+
if (!membership.report)
|
|
103
|
+
return;
|
|
104
|
+
const current = await groups.member(parentID, group, sessionID);
|
|
105
|
+
if (JSON.stringify(current?.report) !== JSON.stringify(membership.report))
|
|
106
|
+
return;
|
|
107
|
+
await groups.put(parentID, group, sessionID, { title, joinedAt });
|
|
108
|
+
}
|
|
109
|
+
/** The claim key of a group's release, in the hub's shared set of deliveries under way. */
|
|
110
|
+
const claimKey = (parentID, group) => `group:${parentID}/${group}`;
|
|
111
|
+
/**
|
|
112
|
+
* Delivers every released group once, from the scheduler's tick: claimed in the shared `claimed`, re-read,
|
|
113
|
+
* delivered and only then dropped, so a failed delivery is tried again at the next tick, and a crash in between delivers it again.
|
|
114
|
+
*/
|
|
115
|
+
export async function deliverReleased(ports, claimed) {
|
|
116
|
+
const groups = [...byGroup(await groupStore(ports.storage).all())].filter(([key, members]) => complete(members) && !claimed.has(key));
|
|
117
|
+
// Claimed all at once, before any delivery, so a tick alongside claims none of these.
|
|
118
|
+
for (const [key] of groups)
|
|
119
|
+
claimed.add(key);
|
|
120
|
+
await Promise.all(groups.map(([key, members]) => releaseClaimed(ports, claimed, key, members[0])));
|
|
121
|
+
}
|
|
122
|
+
async function releaseClaimed(ports, claimed, key, { parentID, group }) {
|
|
123
|
+
try {
|
|
124
|
+
await release(ports, parentID, group);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
ports.log(`courier group ${group} of ${parentID}: ${String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
claimed.delete(key);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Whether the parent's turn that started a group's last member has ended, so no more join it; `gone` when OpenCode no
|
|
135
|
+
* longer knows the parent. A turn ending stamps the session's `time.idle`, which a shutdown does not.
|
|
136
|
+
*/
|
|
137
|
+
async function sealed(ports, parentID, members) {
|
|
138
|
+
const joined = Math.max(...members.map((member) => member.joinedAt));
|
|
139
|
+
try {
|
|
140
|
+
const { time } = await ports.session.get({ sessionID: parentID });
|
|
141
|
+
// NaN, for a parent that has ended no turn yet, holds it.
|
|
142
|
+
return millis(time.idle) >= joined;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (isNotFound(error))
|
|
146
|
+
return "gone";
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function release(ports, parentID, group) {
|
|
151
|
+
// Re-read: another server may have delivered and dropped it since the scan.
|
|
152
|
+
const members = (await groupStore(ports.storage).ofGroup(parentID, group)).sort((a, b) => a.joinedAt - b.joinedAt);
|
|
153
|
+
if (!members.length || !complete(members))
|
|
154
|
+
return;
|
|
155
|
+
const seal = await sealed(ports, parentID, members);
|
|
156
|
+
if (!seal)
|
|
157
|
+
return;
|
|
158
|
+
if (seal === "gone")
|
|
159
|
+
return drop(ports, parentID, group, members, "the session is gone");
|
|
160
|
+
const reports = members.flatMap((member) => (member.report ? [{ sessionID: member.sessionID, title: member.title, ...member.report }] : []));
|
|
161
|
+
const left = members.flatMap((member) => (member.left ? [{ sessionID: member.sessionID, title: member.title, by: member.left.by }] : []));
|
|
162
|
+
if (reports.length) {
|
|
163
|
+
try {
|
|
164
|
+
await ports.session.synthetic({
|
|
165
|
+
sessionID: parentID,
|
|
166
|
+
text: envelope(reports.map((report) => report.sessionID).join(","), groupNotice(group, reports, left), {
|
|
167
|
+
group,
|
|
168
|
+
reports: String(reports.length),
|
|
169
|
+
}),
|
|
170
|
+
description: `Reports of group ${group}`,
|
|
171
|
+
metadata: { source: "courier", from: reports.map((report) => report.sessionID).join(","), group, reports: reports.length },
|
|
172
|
+
delivery: "steer",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// A parent OpenCode no longer knows will never take it: dropped, like the children of a deleted session.
|
|
177
|
+
if (!isNotFound(error)) {
|
|
178
|
+
ports.log(`courier group ${group} of ${parentID} not delivered, held for another try: ${String(error)}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
return drop(ports, parentID, group, members, `the session is gone (${String(error)})`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
await drop(ports, parentID, group, members);
|
|
185
|
+
}
|
|
186
|
+
/** Drops a group's members once delivered, or unsent, saying why. */
|
|
187
|
+
async function drop(ports, parentID, group, members, unsent) {
|
|
188
|
+
if (unsent)
|
|
189
|
+
ports.log(`courier group ${group} of ${parentID} dropped: ${unsent}`);
|
|
190
|
+
// A member left behind here is delivered again at the next tick, as after a crash: logged, so it can be seen.
|
|
191
|
+
const groups = groupStore(ports.storage);
|
|
192
|
+
const dropped = await Promise.allSettled(members.map((member) => dropDelivered(groups, member)));
|
|
193
|
+
for (const [index, result] of dropped.entries())
|
|
194
|
+
if (result.status === "rejected")
|
|
195
|
+
ports.log(`courier group ${group} of ${parentID}: delivered, but ${members[index].sessionID} could not be dropped: ${String(result.reason)}`);
|
|
196
|
+
}
|
|
197
|
+
/** Drops a delivered member's key, unless it was written again since it was read: that report is still to deliver. */
|
|
198
|
+
async function dropDelivered(groups, delivered) {
|
|
199
|
+
const { parentID, group, sessionID, ...read } = delivered;
|
|
200
|
+
if (JSON.stringify(await groups.member(parentID, group, sessionID)) !== JSON.stringify(read))
|
|
201
|
+
return;
|
|
202
|
+
await groups.remove(parentID, group, sessionID);
|
|
203
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
|
|
2
|
+
export { shapeContext } from "./context.js";
|
|
3
|
+
export { lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
4
|
+
export { together, treeEvents, waitsOn } from "./events.js";
|
|
5
|
+
export { deliverReleased } from "./group.js";
|
|
6
|
+
export type { CleanupPorts, CourierPorts, Pending, PendingPrompt, RosterEntry, RosterStorage, SpawnGate, Waits, WatchPorts, WatchState, } from "./ports.js";
|
|
7
|
+
export { allEntries, answeringTop, lineage, pruneExpired, RETENTION_MS } from "./roster.js";
|
|
8
|
+
export { subtree } from "./subtree.js";
|
|
9
|
+
export { registerTreeRpc } from "./rpc-handlers.js";
|
|
10
|
+
export { addTreeTools } from "./tools.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// What the rest of the plugin uses of the session tree: its tools, RPC, event module, context hook and tick job
|
|
2
|
+
// for `src/index.ts`, and the lineage and subtree queries and types `person/` reads.
|
|
3
|
+
export { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
|
|
4
|
+
export { shapeContext } from "./context.js";
|
|
5
|
+
export { lineageIn, nativeRootOf, readLimits, readNativeChildren } from "./domain.js";
|
|
6
|
+
export { together, treeEvents, waitsOn } from "./events.js";
|
|
7
|
+
export { deliverReleased } from "./group.js";
|
|
8
|
+
export { allEntries, answeringTop, lineage, pruneExpired, RETENTION_MS } from "./roster.js";
|
|
9
|
+
export { subtree } from "./subtree.js";
|
|
10
|
+
export { registerTreeRpc } from "./rpc-handlers.js";
|
|
11
|
+
export { addTreeTools } from "./tools.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LimitPorts, Reservation } from "./ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* Checks the limits for a spawn from `parentID`, then holds its place in `gate` until `release`;
|
|
4
|
+
* throws a refusal naming the limit. Checks run one at a time, so spawns made together count each other.
|
|
5
|
+
*/
|
|
6
|
+
export declare function admit(ports: LimitPorts, parentID: string): Promise<{
|
|
7
|
+
depth: number;
|
|
8
|
+
reservation: Reservation;
|
|
9
|
+
release: () => void;
|
|
10
|
+
}>;
|
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { childrenRefusal, depthRefusal, ROLE_PREFIX, rolePart, totalRefusal } from "./notices.js";
|
|
1
|
+
import { isNotFound } from "../platform/json.js";
|
|
2
|
+
import { busy, bySession, lineageBy } from "./domain.js";
|
|
4
3
|
import { reportOf } from "./report.js";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
/** The limits in the plugin's options; one that is not a positive integer keeps its default and is named in `problems`. */
|
|
8
|
-
export function readLimits(options) {
|
|
9
|
-
const given = obj(options);
|
|
10
|
-
const problems = [];
|
|
11
|
-
const read = (name) => {
|
|
12
|
-
const value = given[name];
|
|
13
|
-
if (value === undefined)
|
|
14
|
-
return DEFAULT_LIMITS[name];
|
|
15
|
-
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
16
|
-
return value;
|
|
17
|
-
problems.push(`${name} must be a positive integer, not ${JSON.stringify(value)}; using ${DEFAULT_LIMITS[name]}`);
|
|
18
|
-
return DEFAULT_LIMITS[name];
|
|
19
|
-
};
|
|
20
|
-
const limits = { maxDepth: read("maxDepth"), maxChildren: read("maxChildren"), maxTotal: read("maxTotal") };
|
|
21
|
-
return { limits, problems };
|
|
22
|
-
}
|
|
23
|
-
/** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
|
|
24
|
-
export const busy = (time) => time.idle === undefined || millis(time.updated) > millis(time.idle);
|
|
4
|
+
import { childStore } from "./store.js";
|
|
5
|
+
import { childrenRefusal, depthRefusal, totalRefusal } from "./text.js";
|
|
25
6
|
/**
|
|
26
7
|
* Whether a spawned session counts against the limits: it runs, unless its turn failed or was
|
|
27
8
|
* interrupted since its last prompt, or it owes its parent a report. One OpenCode no longer knows does not.
|
|
@@ -29,7 +10,7 @@ export const busy = (time) => time.idle === undefined || millis(time.updated) >
|
|
|
29
10
|
async function live(ports, sessionID) {
|
|
30
11
|
const [time, report] = await Promise.all([
|
|
31
12
|
ports.session.get({ sessionID }).then((info) => info.time, (error) => (isNotFound(error) ? null : undefined)),
|
|
32
|
-
reportOf(ports.storage, sessionID).catch(() => undefined),
|
|
13
|
+
reportOf(childStore(ports.storage), sessionID).catch(() => undefined),
|
|
33
14
|
]);
|
|
34
15
|
if (time === null)
|
|
35
16
|
return false;
|
|
@@ -53,7 +34,7 @@ export async function admit(ports, parentID) {
|
|
|
53
34
|
}
|
|
54
35
|
async function check(ports, parentID) {
|
|
55
36
|
const { maxDepth, maxChildren, maxTotal } = ports.limits;
|
|
56
|
-
const entries = await
|
|
37
|
+
const entries = await childStore(ports.storage).all();
|
|
57
38
|
const parents = bySession(entries);
|
|
58
39
|
const chain = lineageBy(parents, parentID);
|
|
59
40
|
if (chain.length >= maxDepth)
|
|
@@ -77,33 +58,3 @@ async function check(ports, parentID) {
|
|
|
77
58
|
ports.gate.reserved.add(reservation);
|
|
78
59
|
return { depth: chain.length + 1, reservation };
|
|
79
60
|
}
|
|
80
|
-
/** How many sessions' depths an instance remembers. */
|
|
81
|
-
const ROLES_MAX = 1000;
|
|
82
|
-
/**
|
|
83
|
-
* The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn
|
|
84
|
-
* from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
|
|
85
|
-
*/
|
|
86
|
-
export async function shapeContext(ports, event) {
|
|
87
|
-
try {
|
|
88
|
-
const depth = await depthOf(ports, event.sessionID);
|
|
89
|
-
if (depth === null && !event.tools.courier_spawn)
|
|
90
|
-
return;
|
|
91
|
-
if (depth !== null && depth >= ports.limits.maxDepth)
|
|
92
|
-
delete event.tools.courier_spawn;
|
|
93
|
-
if (!event.system.some((part) => part.text.startsWith(ROLE_PREFIX)))
|
|
94
|
-
event.system.push({ type: "text", text: rolePart(depth, ports.limits) });
|
|
95
|
-
}
|
|
96
|
-
catch (error) {
|
|
97
|
-
ports.log(`courier: cannot tell the role of session ${event.sessionID}: ${String(error)}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
async function depthOf(ports, sessionID) {
|
|
101
|
-
const known = ports.roles.get(sessionID);
|
|
102
|
-
if (known !== undefined)
|
|
103
|
-
return known;
|
|
104
|
-
// The reverse index alone: a scan here would run for every session's first request, courier or not.
|
|
105
|
-
const chain = await indexedLineage(ports.storage, sessionID);
|
|
106
|
-
const depth = chain.length || ((await children(ports.storage, sessionID)).length ? 0 : null);
|
|
107
|
-
setBounded(ports.roles, sessionID, depth, ROLES_MAX);
|
|
108
|
-
return depth;
|
|
109
|
-
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CourierPorts, Pending } from "./ports.js";
|
|
2
|
+
import type { Tree, Wait } from "./rpc.js";
|
|
3
|
+
type Messages = Awaited<ReturnType<CourierPorts["session"]["context"]>>;
|
|
4
|
+
/** The last tool call in a session's messages, and when it was made. */
|
|
5
|
+
export declare function lastTool(messages: Messages): {
|
|
6
|
+
name: string;
|
|
7
|
+
at: number;
|
|
8
|
+
} | undefined;
|
|
9
|
+
/** A request as the panel says what a paused session waits on: a permission request, or its first question. */
|
|
10
|
+
export declare const waitOf: (pending: Pending) => Wait;
|
|
11
|
+
/** The tree panel's rows: the sessions courier_tree reads under a session, at most `maxTotal`, each as `row` reads it. */
|
|
12
|
+
export declare function panel(ports: CourierPorts, rootID: string): Promise<Tree>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { busy } from "./domain.js";
|
|
2
|
+
import { lastStatus, reportOf } from "./report.js";
|
|
3
|
+
import { standingIn } from "./status.js";
|
|
4
|
+
import { childStore } from "./store.js";
|
|
5
|
+
import { subtree } from "./subtree.js";
|
|
6
|
+
/** The last tool call in a session's messages, and when it was made. */
|
|
7
|
+
export function lastTool(messages) {
|
|
8
|
+
for (const message of messages.toReversed()) {
|
|
9
|
+
if (message.type !== "assistant")
|
|
10
|
+
continue;
|
|
11
|
+
const tool = message.content.findLast((part) => part.type === "tool");
|
|
12
|
+
if (tool?.type === "tool")
|
|
13
|
+
return { name: tool.name, at: tool.time.ran ?? tool.time.created };
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
/** A request as the panel says what a paused session waits on: a permission request, or its first question. */
|
|
18
|
+
export const waitOf = (pending) => pending.type === "permission"
|
|
19
|
+
? { type: "permission", action: pending.action, resources: [...pending.resources] }
|
|
20
|
+
: { type: "question", question: pending.questions[0]?.question ?? "" };
|
|
21
|
+
/** A session's report since its last prompt, as the panel shows it; none for one spawned before this was kept. */
|
|
22
|
+
function reportRow(report, status) {
|
|
23
|
+
if (!report)
|
|
24
|
+
return {};
|
|
25
|
+
if (report.owes)
|
|
26
|
+
return { report: { state: "owed", progressed: report.progressed } };
|
|
27
|
+
if (report.ended)
|
|
28
|
+
return { report: { state: "ended", progressed: report.progressed } };
|
|
29
|
+
return { report: { state: "reported", progressed: report.progressed, ...(status ? { status } : {}) } };
|
|
30
|
+
}
|
|
31
|
+
/** What a session does: waiting on a request to the person, running, idle, or gone when OpenCode no longer knows it. */
|
|
32
|
+
function stateOf(info, waits, running) {
|
|
33
|
+
if (!info)
|
|
34
|
+
return "gone";
|
|
35
|
+
if (waits)
|
|
36
|
+
return "waiting";
|
|
37
|
+
return running ? "running" : "idle";
|
|
38
|
+
}
|
|
39
|
+
/** One session of the panel: what it is doing, its report and group standing, its last tool call and its cost. */
|
|
40
|
+
async function row(ports, entry, depth) {
|
|
41
|
+
const { sessionID } = entry;
|
|
42
|
+
const children = childStore(ports.storage);
|
|
43
|
+
const [info, messages, pending, report, status, standing] = await Promise.all([
|
|
44
|
+
ports.session.get({ sessionID }).catch(() => undefined),
|
|
45
|
+
ports.session.context({ sessionID }).catch(() => []),
|
|
46
|
+
ports.pending(sessionID).catch(() => []),
|
|
47
|
+
reportOf(children, sessionID).catch(() => undefined),
|
|
48
|
+
lastStatus(children, sessionID).catch(() => undefined),
|
|
49
|
+
entry.group ? standingIn(ports, entry, entry.group) : undefined,
|
|
50
|
+
]);
|
|
51
|
+
const running = info !== undefined && busy(info.time);
|
|
52
|
+
const tool = lastTool(messages);
|
|
53
|
+
return {
|
|
54
|
+
sessionID,
|
|
55
|
+
parentID: entry.parentID,
|
|
56
|
+
title: info?.title ?? entry.title,
|
|
57
|
+
depth,
|
|
58
|
+
state: stateOf(info, pending.length, running),
|
|
59
|
+
...(info?.outcome ? { outcome: info.outcome } : {}),
|
|
60
|
+
...reportRow(report, status),
|
|
61
|
+
...(entry.group && standing ? { group: { name: entry.group, standing } } : {}),
|
|
62
|
+
isolated: entry.isolated,
|
|
63
|
+
created: info?.time.created ?? entry.createdAt,
|
|
64
|
+
...(info && !running && info.time.idle !== undefined ? { idle: info.time.idle } : {}),
|
|
65
|
+
...(info ? { cost: info.cost } : {}),
|
|
66
|
+
...(tool ? { tool } : {}),
|
|
67
|
+
waits: pending.map(waitOf),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The tree panel's rows: the sessions courier_tree reads under a session, at most `maxTotal`, each as `row` reads it. */
|
|
71
|
+
export async function panel(ports, rootID) {
|
|
72
|
+
const { nodes, truncated } = await subtree(ports.storage, rootID, ports.limits.maxTotal);
|
|
73
|
+
const rows = await Promise.all(nodes.map(({ entry, depth }) => row(ports, entry, depth)));
|
|
74
|
+
return { rows, ...(truncated ? { truncated: ports.limits.maxTotal } : {}) };
|
|
75
|
+
}
|