opencode-superagent 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/LICENSE +21 -0
- package/README.md +333 -2
- package/agent/superagent-router.md +7 -0
- package/agent/superagent-worker.md +10 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +115 -0
- package/dist/src/abort.d.ts +31 -0
- package/dist/src/abort.js +176 -0
- package/dist/src/agents.d.ts +51 -0
- package/dist/src/agents.js +83 -0
- package/dist/src/context.d.ts +20 -0
- package/dist/src/context.js +98 -0
- package/dist/src/dirs.d.ts +45 -0
- package/dist/src/dirs.js +129 -0
- package/dist/src/dispatch.d.ts +38 -0
- package/dist/src/dispatch.js +214 -0
- package/dist/src/format.d.ts +3 -0
- package/dist/src/format.js +14 -0
- package/dist/src/guard.d.ts +4 -0
- package/dist/src/guard.js +9 -0
- package/dist/src/prompts.d.ts +1 -0
- package/dist/src/prompts.js +36 -0
- package/dist/src/protocol.d.ts +210 -0
- package/dist/src/protocol.js +88 -0
- package/dist/src/recall.d.ts +58 -0
- package/dist/src/recall.js +309 -0
- package/dist/src/report.d.ts +72 -0
- package/dist/src/report.js +147 -0
- package/dist/src/router.d.ts +35 -0
- package/dist/src/router.js +209 -0
- package/dist/src/session-meta.d.ts +42 -0
- package/dist/src/session-meta.js +91 -0
- package/dist/src/state.d.ts +124 -0
- package/dist/src/state.js +431 -0
- package/dist/src/ticket-tools.d.ts +45 -0
- package/dist/src/ticket-tools.js +148 -0
- package/dist/src/ticket-view.d.ts +62 -0
- package/dist/src/ticket-view.js +135 -0
- package/dist/src/ticket.d.ts +246 -0
- package/dist/src/ticket.js +700 -0
- package/dist/src/tools.d.ts +85 -0
- package/dist/src/tools.js +376 -0
- package/dist/src/watchdog.d.ts +16 -0
- package/dist/src/watchdog.js +77 -0
- package/dist/src/watcher.d.ts +11 -0
- package/dist/src/watcher.js +181 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1027 -0
- package/package.json +79 -4
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
const ROUTES = ["router", "worker"];
|
|
3
|
+
/** Host context for fire-and-forget KV writes from synchronous interception points. Set during hydrate. */
|
|
4
|
+
let persistHost;
|
|
5
|
+
export function triggerIdFor(input) {
|
|
6
|
+
return input.id ?? `${input.source}:${input.threadID}:${input.createdAt ?? Date.now()}`;
|
|
7
|
+
}
|
|
8
|
+
function normalizeTrigger(input) {
|
|
9
|
+
return {
|
|
10
|
+
...input,
|
|
11
|
+
id: triggerIdFor(input),
|
|
12
|
+
createdAt: input.createdAt ?? Date.now(),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function schedulePersist(record) {
|
|
16
|
+
const host = persistHost;
|
|
17
|
+
if (host === undefined)
|
|
18
|
+
return;
|
|
19
|
+
const snapshot = { ...record };
|
|
20
|
+
Effect.runFork(persistTrigger(host, snapshot).pipe(Effect.catchCause(() => Effect.void)));
|
|
21
|
+
}
|
|
22
|
+
function rememberQueued(input) {
|
|
23
|
+
const id = input.id ?? triggerIdFor(input);
|
|
24
|
+
const delivered = store.triggers.get(id);
|
|
25
|
+
const record = {
|
|
26
|
+
id,
|
|
27
|
+
source: input.source,
|
|
28
|
+
threadID: input.threadID,
|
|
29
|
+
...(input.author === undefined ? {} : { author: input.author }),
|
|
30
|
+
text: input.text,
|
|
31
|
+
...(input.title === undefined ? {} : { title: input.title }),
|
|
32
|
+
createdAt: input.createdAt ?? Date.now(),
|
|
33
|
+
...(delivered !== undefined && delivered.route !== undefined ? { route: delivered.route } : {}),
|
|
34
|
+
...(delivered !== undefined && delivered.sessionID !== undefined ? { sessionID: delivered.sessionID } : {}),
|
|
35
|
+
};
|
|
36
|
+
if (!store.triggers.has(id))
|
|
37
|
+
store.triggerTotal += 1;
|
|
38
|
+
store.triggers.set(id, record);
|
|
39
|
+
schedulePersist(record);
|
|
40
|
+
}
|
|
41
|
+
function markFlushed(removed) {
|
|
42
|
+
for (const item of removed) {
|
|
43
|
+
const id = item.trigger.id ?? triggerIdFor(item.trigger);
|
|
44
|
+
const record = store.triggers.get(id);
|
|
45
|
+
if (record === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
record.route = "router";
|
|
48
|
+
if (store.routerID !== undefined)
|
|
49
|
+
record.sessionID = store.routerID;
|
|
50
|
+
schedulePersist(record);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Queue that transparently records trigger history. `server.ts` and
|
|
55
|
+
* `router.ts` keep using plain `push`/`splice`; interception here writes
|
|
56
|
+
* the memory map synchronously and persists to KV in the background, so
|
|
57
|
+
* no caller changes are needed.
|
|
58
|
+
*/
|
|
59
|
+
export class PendingQueue extends Array {
|
|
60
|
+
push(...items) {
|
|
61
|
+
const normalized = items.map((item) => ({ trigger: normalizeTrigger(item.trigger), arrivedAt: item.arrivedAt }));
|
|
62
|
+
for (const item of normalized)
|
|
63
|
+
rememberQueued(item.trigger);
|
|
64
|
+
return super.push(...normalized);
|
|
65
|
+
}
|
|
66
|
+
splice(start, deleteCount, ...items) {
|
|
67
|
+
const removed = deleteCount === undefined ? super.splice(start) : super.splice(start, deleteCount, ...items);
|
|
68
|
+
if (removed.length > 0)
|
|
69
|
+
markFlushed(removed);
|
|
70
|
+
return removed;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Record a trigger delivered straight to a worker (it never passes the
|
|
75
|
+
* pending queue). The text comes from the admitted prompt, so it carries
|
|
76
|
+
* the same truncation as the worker-visible format; queued triggers keep
|
|
77
|
+
* their full text via `rememberQueued`.
|
|
78
|
+
*/
|
|
79
|
+
export function upsertDirectTrigger(input, sessionID) {
|
|
80
|
+
const existing = store.triggers.get(input.id);
|
|
81
|
+
if (existing !== undefined) {
|
|
82
|
+
if (existing.route === undefined)
|
|
83
|
+
existing.route = "worker";
|
|
84
|
+
if (existing.sessionID === undefined)
|
|
85
|
+
existing.sessionID = sessionID;
|
|
86
|
+
schedulePersist(existing);
|
|
87
|
+
return existing;
|
|
88
|
+
}
|
|
89
|
+
const record = {
|
|
90
|
+
id: input.id,
|
|
91
|
+
source: input.source,
|
|
92
|
+
threadID: input.threadID,
|
|
93
|
+
...(input.author === undefined ? {} : { author: input.author }),
|
|
94
|
+
text: input.text,
|
|
95
|
+
createdAt: input.createdAt,
|
|
96
|
+
route: "worker",
|
|
97
|
+
sessionID,
|
|
98
|
+
};
|
|
99
|
+
store.triggers.set(input.id, record);
|
|
100
|
+
store.triggerTotal += 1;
|
|
101
|
+
schedulePersist(record);
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
export const store = {
|
|
105
|
+
routerID: undefined,
|
|
106
|
+
routerBusy: false,
|
|
107
|
+
workers: new Map(),
|
|
108
|
+
threads: new Map(),
|
|
109
|
+
pending: new PendingQueue(),
|
|
110
|
+
triggers: new Map(),
|
|
111
|
+
triggerTotal: 0,
|
|
112
|
+
};
|
|
113
|
+
export const ROUTER_KEY = "router";
|
|
114
|
+
export const WORKER_PREFIX = "worker:";
|
|
115
|
+
export const THREAD_PREFIX = "thread:";
|
|
116
|
+
export const TRIGGER_PREFIX = "trigger:";
|
|
117
|
+
const RECENT_TRIGGER_LIMIT = 200;
|
|
118
|
+
export const workerKey = (sessionID) => `${WORKER_PREFIX}${sessionID}`;
|
|
119
|
+
export const threadKey = (threadID) => `${THREAD_PREFIX}${threadID}`;
|
|
120
|
+
export const triggerKey = (id) => `${TRIGGER_PREFIX}${id}`;
|
|
121
|
+
/** Brands are compile-time only, so a validated session id string converts freely. */
|
|
122
|
+
export const toSessionID = (id) => id;
|
|
123
|
+
const STATES = ["running", "idle", "failed", "done", "aborted"];
|
|
124
|
+
function isRecord(value) {
|
|
125
|
+
return typeof value === "object" && value !== null;
|
|
126
|
+
}
|
|
127
|
+
function optionalString(record, key) {
|
|
128
|
+
const value = record[key];
|
|
129
|
+
return typeof value === "string" ? value : undefined;
|
|
130
|
+
}
|
|
131
|
+
function optionalNumber(record, key) {
|
|
132
|
+
const value = record[key];
|
|
133
|
+
return typeof value === "number" ? value : undefined;
|
|
134
|
+
}
|
|
135
|
+
function stringArray(value) {
|
|
136
|
+
if (!Array.isArray(value))
|
|
137
|
+
return undefined;
|
|
138
|
+
if (!value.every((item) => typeof item === "string"))
|
|
139
|
+
return undefined;
|
|
140
|
+
return [...value];
|
|
141
|
+
}
|
|
142
|
+
export function decodeWorker(value) {
|
|
143
|
+
if (!isRecord(value))
|
|
144
|
+
return undefined;
|
|
145
|
+
if (typeof value["sessionID"] !== "string")
|
|
146
|
+
return undefined;
|
|
147
|
+
if (typeof value["state"] !== "string" || !STATES.includes(value["state"]))
|
|
148
|
+
return undefined;
|
|
149
|
+
const threads = stringArray(value["threads"]);
|
|
150
|
+
if (threads === undefined)
|
|
151
|
+
return undefined;
|
|
152
|
+
const createdAt = optionalNumber(value, "createdAt") ?? 0;
|
|
153
|
+
const triggerID = optionalString(value, "triggerID");
|
|
154
|
+
return {
|
|
155
|
+
sessionID: value["sessionID"],
|
|
156
|
+
title: optionalString(value, "title"),
|
|
157
|
+
// Legacy records have no `threadID`; hydrate backfills it from the thread
|
|
158
|
+
// record (or `threads[0]`) and persists the result.
|
|
159
|
+
threadID: optionalString(value, "threadID") ?? "",
|
|
160
|
+
...(triggerID === undefined ? {} : { triggerID }),
|
|
161
|
+
threads,
|
|
162
|
+
state: value["state"],
|
|
163
|
+
createdAt,
|
|
164
|
+
updatedAt: optionalNumber(value, "updatedAt") ?? createdAt,
|
|
165
|
+
lastReport: optionalString(value, "lastReport"),
|
|
166
|
+
...(optionalString(value, "directory") === undefined ? {} : { directory: optionalString(value, "directory") }),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export function decodeThread(value) {
|
|
170
|
+
if (!isRecord(value))
|
|
171
|
+
return undefined;
|
|
172
|
+
if (typeof value["threadID"] !== "string")
|
|
173
|
+
return undefined;
|
|
174
|
+
const workers = stringArray(value["workers"]);
|
|
175
|
+
if (workers === undefined)
|
|
176
|
+
return undefined;
|
|
177
|
+
return {
|
|
178
|
+
threadID: value["threadID"],
|
|
179
|
+
workers,
|
|
180
|
+
updatedAt: optionalNumber(value, "updatedAt") ?? 0,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function decodeTrigger(value) {
|
|
184
|
+
if (!isRecord(value))
|
|
185
|
+
return undefined;
|
|
186
|
+
if (typeof value["id"] !== "string")
|
|
187
|
+
return undefined;
|
|
188
|
+
if (typeof value["source"] !== "string")
|
|
189
|
+
return undefined;
|
|
190
|
+
if (typeof value["threadID"] !== "string")
|
|
191
|
+
return undefined;
|
|
192
|
+
if (typeof value["text"] !== "string")
|
|
193
|
+
return undefined;
|
|
194
|
+
const route = value["route"];
|
|
195
|
+
if (route !== undefined && (typeof route !== "string" || !ROUTES.includes(route)))
|
|
196
|
+
return undefined;
|
|
197
|
+
const sessionID = optionalString(value, "sessionID");
|
|
198
|
+
const workerIDs = stringArray(value["workerIDs"]);
|
|
199
|
+
return {
|
|
200
|
+
id: value["id"],
|
|
201
|
+
source: value["source"],
|
|
202
|
+
threadID: value["threadID"],
|
|
203
|
+
author: optionalString(value, "author"),
|
|
204
|
+
text: value["text"],
|
|
205
|
+
title: optionalString(value, "title"),
|
|
206
|
+
createdAt: optionalNumber(value, "createdAt") ?? 0,
|
|
207
|
+
...(route === undefined ? {} : { route: route }),
|
|
208
|
+
...(sessionID === undefined ? {} : { sessionID }),
|
|
209
|
+
...(workerIDs === undefined || workerIDs.length === 0 ? {} : { workerIDs }),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
export function encodeWorker(record) {
|
|
213
|
+
return {
|
|
214
|
+
sessionID: record.sessionID,
|
|
215
|
+
...(record.title === undefined ? {} : { title: record.title }),
|
|
216
|
+
...(record.threadID === "" ? {} : { threadID: record.threadID }),
|
|
217
|
+
...(record.triggerID === undefined ? {} : { triggerID: record.triggerID }),
|
|
218
|
+
threads: [...record.threads],
|
|
219
|
+
state: record.state,
|
|
220
|
+
createdAt: record.createdAt,
|
|
221
|
+
...(record.updatedAt === undefined ? {} : { updatedAt: record.updatedAt }),
|
|
222
|
+
...(record.lastReport === undefined ? {} : { lastReport: record.lastReport }),
|
|
223
|
+
...(record.directory === undefined ? {} : { directory: record.directory }),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export function encodeThread(record) {
|
|
227
|
+
return {
|
|
228
|
+
threadID: record.threadID,
|
|
229
|
+
workers: [...record.workers],
|
|
230
|
+
updatedAt: record.updatedAt,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
export function encodeTrigger(record) {
|
|
234
|
+
return {
|
|
235
|
+
id: record.id,
|
|
236
|
+
source: record.source,
|
|
237
|
+
threadID: record.threadID,
|
|
238
|
+
...(record.author === undefined ? {} : { author: record.author }),
|
|
239
|
+
text: record.text,
|
|
240
|
+
...(record.title === undefined ? {} : { title: record.title }),
|
|
241
|
+
createdAt: record.createdAt,
|
|
242
|
+
...(record.route === undefined ? {} : { route: record.route }),
|
|
243
|
+
...(record.sessionID === undefined ? {} : { sessionID: record.sessionID }),
|
|
244
|
+
...(record.workerIDs === undefined || record.workerIDs.length === 0 ? {} : { workerIDs: [...record.workerIDs] }),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
const persistWorker = Effect.fn("superagent.persistWorker")(function* (ctx, record) {
|
|
248
|
+
yield* ctx.storage
|
|
249
|
+
.set(workerKey(record.sessionID), encodeWorker(record))
|
|
250
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist worker", { cause })));
|
|
251
|
+
});
|
|
252
|
+
const persistTrigger = Effect.fn("superagent.persistTrigger")(function* (ctx, record) {
|
|
253
|
+
yield* ctx.storage
|
|
254
|
+
.set(triggerKey(record.id), encodeTrigger(record))
|
|
255
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist trigger", { cause })));
|
|
256
|
+
});
|
|
257
|
+
const removeWorker = Effect.fn("superagent.removeWorker")(function* (ctx, sessionID) {
|
|
258
|
+
store.workers.delete(sessionID);
|
|
259
|
+
yield* ctx.storage
|
|
260
|
+
.remove(workerKey(sessionID))
|
|
261
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to remove worker", { cause })));
|
|
262
|
+
});
|
|
263
|
+
/**
|
|
264
|
+
* Load KV state into the in-memory maps. Storage is the unique source of
|
|
265
|
+
* truth; every loaded session is validated with `session.get` and stale
|
|
266
|
+
* entries are cleaned from both memory and KV.
|
|
267
|
+
*/
|
|
268
|
+
export const hydrate = Effect.fn("superagent.hydrate")(function* (ctx) {
|
|
269
|
+
persistHost = ctx;
|
|
270
|
+
const sessions = ctx.session;
|
|
271
|
+
const storage = ctx.storage;
|
|
272
|
+
const storedRouter = yield* storage.get(ROUTER_KEY).pipe(Effect.catch(() => Effect.succeed(undefined)));
|
|
273
|
+
if (typeof storedRouter === "string") {
|
|
274
|
+
const info = yield* sessions.get({ sessionID: toSessionID(storedRouter) }).pipe(Effect.map((session) => session), Effect.catch(() => Effect.succeed(undefined)));
|
|
275
|
+
if (info === undefined) {
|
|
276
|
+
yield* storage.remove(ROUTER_KEY).pipe(Effect.catch(() => Effect.void));
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
store.routerID = info.id;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
let after;
|
|
283
|
+
do {
|
|
284
|
+
const page = yield* storage
|
|
285
|
+
.scan({ prefix: WORKER_PREFIX, after, limit: 100 })
|
|
286
|
+
.pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
|
|
287
|
+
for (const entry of page.entries) {
|
|
288
|
+
const record = decodeWorker(entry.value);
|
|
289
|
+
if (record === undefined) {
|
|
290
|
+
yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const info = yield* sessions.get({ sessionID: toSessionID(record.sessionID) }).pipe(Effect.map((session) => session), Effect.catch(() => Effect.succeed(undefined)));
|
|
294
|
+
if (info === undefined) {
|
|
295
|
+
yield* removeWorker(ctx, record.sessionID);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
store.workers.set(record.sessionID, record);
|
|
299
|
+
}
|
|
300
|
+
after = page.next;
|
|
301
|
+
} while (after !== undefined);
|
|
302
|
+
let threadAfter;
|
|
303
|
+
do {
|
|
304
|
+
const page = yield* storage
|
|
305
|
+
.scan({ prefix: THREAD_PREFIX, after: threadAfter, limit: 100 })
|
|
306
|
+
.pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
|
|
307
|
+
for (const entry of page.entries) {
|
|
308
|
+
const record = decodeThread(entry.value);
|
|
309
|
+
if (record === undefined) {
|
|
310
|
+
yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const alive = record.workers.filter((sessionID) => store.workers.has(sessionID));
|
|
314
|
+
if (alive.length === 0) {
|
|
315
|
+
yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
store.threads.set(record.threadID, alive);
|
|
319
|
+
}
|
|
320
|
+
threadAfter = page.next;
|
|
321
|
+
} while (threadAfter !== undefined);
|
|
322
|
+
const collected = [];
|
|
323
|
+
let triggerAfter;
|
|
324
|
+
do {
|
|
325
|
+
const page = yield* storage
|
|
326
|
+
.scan({ prefix: TRIGGER_PREFIX, after: triggerAfter, limit: 100 })
|
|
327
|
+
.pipe(Effect.catch(() => Effect.succeed({ entries: [], next: undefined })));
|
|
328
|
+
for (const entry of page.entries) {
|
|
329
|
+
const record = decodeTrigger(entry.value);
|
|
330
|
+
if (record === undefined) {
|
|
331
|
+
yield* storage.remove(entry.key).pipe(Effect.catch(() => Effect.void));
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
collected.push(record);
|
|
335
|
+
}
|
|
336
|
+
triggerAfter = page.next;
|
|
337
|
+
} while (triggerAfter !== undefined);
|
|
338
|
+
collected.sort((left, right) => right.createdAt - left.createdAt);
|
|
339
|
+
store.triggerTotal = collected.length;
|
|
340
|
+
for (const record of collected.slice(0, RECENT_TRIGGER_LIMIT)) {
|
|
341
|
+
if (!store.triggers.has(record.id))
|
|
342
|
+
store.triggers.set(record.id, record);
|
|
343
|
+
}
|
|
344
|
+
// ── reference graph backfill (idempotent) ──────────────────────────────────
|
|
345
|
+
// New records carry `threadID`/`triggerID`/`workerIDs`; legacy records only
|
|
346
|
+
// have `threads`. Rebuild a missing edge from what is still authoritative
|
|
347
|
+
// (the thread affinity list, or the opposite edge) and persist only changed
|
|
348
|
+
// records, so a second hydrate writes nothing and only a stat line is logged.
|
|
349
|
+
let backfilledWorkers = 0;
|
|
350
|
+
let backfilledTriggers = 0;
|
|
351
|
+
for (const [threadID, workerIDs] of store.threads) {
|
|
352
|
+
for (const sessionID of workerIDs) {
|
|
353
|
+
const worker = store.workers.get(sessionID);
|
|
354
|
+
if (worker === undefined || worker.threadID !== "")
|
|
355
|
+
continue;
|
|
356
|
+
worker.threadID = threadID;
|
|
357
|
+
backfilledWorkers += 1;
|
|
358
|
+
yield* persistWorker(ctx, worker);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
for (const worker of store.workers.values()) {
|
|
362
|
+
if (worker.threadID !== "")
|
|
363
|
+
continue;
|
|
364
|
+
const primary = worker.threads[0];
|
|
365
|
+
if (primary === undefined)
|
|
366
|
+
continue;
|
|
367
|
+
worker.threadID = primary;
|
|
368
|
+
backfilledWorkers += 1;
|
|
369
|
+
yield* persistWorker(ctx, worker);
|
|
370
|
+
}
|
|
371
|
+
for (const worker of store.workers.values()) {
|
|
372
|
+
const triggerID = worker.triggerID;
|
|
373
|
+
if (triggerID === undefined)
|
|
374
|
+
continue;
|
|
375
|
+
const trigger = store.triggers.get(triggerID);
|
|
376
|
+
if (trigger === undefined)
|
|
377
|
+
continue;
|
|
378
|
+
const ids = trigger.workerIDs ?? [];
|
|
379
|
+
if (ids.includes(worker.sessionID))
|
|
380
|
+
continue;
|
|
381
|
+
trigger.workerIDs = [...ids, worker.sessionID];
|
|
382
|
+
backfilledTriggers += 1;
|
|
383
|
+
yield* persistTrigger(ctx, trigger);
|
|
384
|
+
}
|
|
385
|
+
for (const trigger of store.triggers.values()) {
|
|
386
|
+
for (const sessionID of trigger.workerIDs ?? []) {
|
|
387
|
+
const worker = store.workers.get(sessionID);
|
|
388
|
+
if (worker === undefined || worker.triggerID !== undefined)
|
|
389
|
+
continue;
|
|
390
|
+
worker.triggerID = trigger.id;
|
|
391
|
+
backfilledWorkers += 1;
|
|
392
|
+
yield* persistWorker(ctx, worker);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (backfilledWorkers > 0 || backfilledTriggers > 0) {
|
|
396
|
+
yield* Effect.logInfo("superagent reference graph backfilled", {
|
|
397
|
+
workers: backfilledWorkers,
|
|
398
|
+
triggers: backfilledTriggers,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
/** Every worker write refreshes `updatedAt`, so an aborted record keeps a traceable timestamp. */
|
|
403
|
+
export const saveWorker = Effect.fn("superagent.saveWorker")(function* (ctx, record) {
|
|
404
|
+
record.updatedAt = Date.now();
|
|
405
|
+
yield* persistWorker(ctx, record);
|
|
406
|
+
});
|
|
407
|
+
export const saveTrigger = persistTrigger;
|
|
408
|
+
/**
|
|
409
|
+
* Record the worker on the trigger that authorized it (`trigger.workerIDs`),
|
|
410
|
+
* completing the reverse edge of `WorkerRecord.triggerID`. Memory holds only
|
|
411
|
+
* the newest triggers, so an older trigger is read from KV once; a missing
|
|
412
|
+
* trigger is not invented. Repeating the call is a no-op.
|
|
413
|
+
*/
|
|
414
|
+
export const linkTriggerToWorker = Effect.fn("superagent.linkTriggerToWorker")(function* (ctx, triggerID, sessionID) {
|
|
415
|
+
const existing = store.triggers.get(triggerID);
|
|
416
|
+
const record = existing ??
|
|
417
|
+
decodeTrigger(yield* ctx.storage.get(triggerKey(triggerID)).pipe(Effect.catch(() => Effect.succeed(undefined))));
|
|
418
|
+
if (record === undefined)
|
|
419
|
+
return false;
|
|
420
|
+
const workerIDs = record.workerIDs ?? [];
|
|
421
|
+
if (workerIDs.includes(sessionID))
|
|
422
|
+
return false;
|
|
423
|
+
record.workerIDs = [...workerIDs, sessionID];
|
|
424
|
+
yield* persistTrigger(ctx, record);
|
|
425
|
+
return true;
|
|
426
|
+
});
|
|
427
|
+
export const saveThread = Effect.fn("superagent.saveThread")(function* (ctx, record) {
|
|
428
|
+
yield* ctx.storage
|
|
429
|
+
.set(threadKey(record.threadID), encodeThread(record))
|
|
430
|
+
.pipe(Effect.catch((cause) => Effect.logWarning("superagent failed to persist thread", { cause })));
|
|
431
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/effect";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import { type TicketState } from "./ticket.js";
|
|
4
|
+
/**
|
|
5
|
+
* Router-only ticket tools. They are a thin adapter: validation, the state
|
|
6
|
+
* machine, dedupe and KV persistence all live in `src/ticket.ts`, so the same
|
|
7
|
+
* rules apply to the dispatch and watcher links.
|
|
8
|
+
*/
|
|
9
|
+
export declare const TicketCreateInput: Schema.Struct<{
|
|
10
|
+
readonly threadID: Schema.String;
|
|
11
|
+
readonly title: Schema.String;
|
|
12
|
+
readonly objective: Schema.String;
|
|
13
|
+
readonly acceptance: Schema.$Array<Schema.String>;
|
|
14
|
+
readonly directory: Schema.String;
|
|
15
|
+
readonly priority: Schema.optionalKey<Schema.Literals<readonly ["p0", "p1", "p2"]>>;
|
|
16
|
+
readonly parentTicketID: Schema.optionalKey<Schema.String>;
|
|
17
|
+
readonly dedupeKey: Schema.optionalKey<Schema.String>;
|
|
18
|
+
}>;
|
|
19
|
+
export declare const TicketListInput: Schema.Struct<{
|
|
20
|
+
readonly threadID: Schema.optionalKey<Schema.String>;
|
|
21
|
+
readonly status: Schema.optionalKey<Schema.Literals<readonly TicketState[]>>;
|
|
22
|
+
readonly since: Schema.optionalKey<Schema.Number>;
|
|
23
|
+
readonly limit: Schema.optionalKey<Schema.Number>;
|
|
24
|
+
}>;
|
|
25
|
+
export declare const TicketUpdateInput: Schema.Struct<{
|
|
26
|
+
readonly ticketID: Schema.String;
|
|
27
|
+
readonly status: Schema.optionalKey<Schema.Literals<readonly TicketState[]>>;
|
|
28
|
+
readonly note: Schema.optionalKey<Schema.String>;
|
|
29
|
+
}>;
|
|
30
|
+
export declare const TicketCloseInput: Schema.Struct<{
|
|
31
|
+
readonly ticketID: Schema.String;
|
|
32
|
+
readonly outcome: Schema.Literals<readonly ["done", "failed", "cancelled"]>;
|
|
33
|
+
readonly summary: Schema.String;
|
|
34
|
+
}>;
|
|
35
|
+
interface TicketListInput {
|
|
36
|
+
readonly threadID?: string;
|
|
37
|
+
readonly status?: TicketState;
|
|
38
|
+
readonly since?: number;
|
|
39
|
+
readonly limit?: number;
|
|
40
|
+
}
|
|
41
|
+
/** RPC helper for the TUI: ready-to-render text, no router check (the user drove it). */
|
|
42
|
+
export declare function renderTicketsText(input?: TicketListInput): string;
|
|
43
|
+
export declare function renderTicketText(ticketID: string): string;
|
|
44
|
+
export declare const registerTicketTools: (ctx: Plugin.Context) => Effect.Effect<void, never, import("effect/Scope").Scope>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { requireRouter } from "./guard.js";
|
|
3
|
+
import { closeTicket, createTicket, currentTrigger, getTicket, listTickets, renderTicketDetail, renderTicketList, TICKET_STATES, updateTicket, } from "./ticket.js";
|
|
4
|
+
import { specFor, toolPermission } from "./tools.js";
|
|
5
|
+
/**
|
|
6
|
+
* Router-only ticket tools. They are a thin adapter: validation, the state
|
|
7
|
+
* machine, dedupe and KV persistence all live in `src/ticket.ts`, so the same
|
|
8
|
+
* rules apply to the dispatch and watcher links.
|
|
9
|
+
*/
|
|
10
|
+
export const TicketCreateInput = Schema.Struct({
|
|
11
|
+
threadID: Schema.String,
|
|
12
|
+
title: Schema.String,
|
|
13
|
+
objective: Schema.String,
|
|
14
|
+
acceptance: Schema.Array(Schema.String),
|
|
15
|
+
directory: Schema.String,
|
|
16
|
+
priority: Schema.optionalKey(Schema.Literals(["p0", "p1", "p2"])),
|
|
17
|
+
parentTicketID: Schema.optionalKey(Schema.String),
|
|
18
|
+
dedupeKey: Schema.optionalKey(Schema.String),
|
|
19
|
+
});
|
|
20
|
+
export const TicketListInput = Schema.Struct({
|
|
21
|
+
threadID: Schema.optionalKey(Schema.String),
|
|
22
|
+
status: Schema.optionalKey(Schema.Literals(TICKET_STATES)),
|
|
23
|
+
since: Schema.optionalKey(Schema.Number),
|
|
24
|
+
limit: Schema.optionalKey(Schema.Number),
|
|
25
|
+
});
|
|
26
|
+
export const TicketUpdateInput = Schema.Struct({
|
|
27
|
+
ticketID: Schema.String,
|
|
28
|
+
status: Schema.optionalKey(Schema.Literals(TICKET_STATES)),
|
|
29
|
+
note: Schema.optionalKey(Schema.String),
|
|
30
|
+
});
|
|
31
|
+
export const TicketCloseInput = Schema.Struct({
|
|
32
|
+
ticketID: Schema.String,
|
|
33
|
+
outcome: Schema.Literals(["done", "failed", "cancelled"]),
|
|
34
|
+
summary: Schema.String,
|
|
35
|
+
});
|
|
36
|
+
const TicketRefOutput = Schema.Struct({
|
|
37
|
+
ticketID: Schema.String,
|
|
38
|
+
state: Schema.String,
|
|
39
|
+
reused: Schema.Boolean,
|
|
40
|
+
});
|
|
41
|
+
const TicketTextOutput = Schema.Struct({ text: Schema.String });
|
|
42
|
+
const runTicketCreate = Effect.fn("superagent.ticketCreate")(function* (ctx, input, caller) {
|
|
43
|
+
yield* requireRouter(caller, "ticket_create");
|
|
44
|
+
const trigger = currentTrigger();
|
|
45
|
+
const threadID = input.threadID.trim();
|
|
46
|
+
const requester = trigger?.threadID === threadID ? trigger : undefined;
|
|
47
|
+
const result = yield* createTicket(ctx, {
|
|
48
|
+
threadID,
|
|
49
|
+
title: input.title,
|
|
50
|
+
objective: input.objective,
|
|
51
|
+
acceptance: input.acceptance,
|
|
52
|
+
directory: input.directory,
|
|
53
|
+
source: requester?.source ?? "router",
|
|
54
|
+
...(requester?.author === undefined ? {} : { author: requester.author }),
|
|
55
|
+
...(input.priority === undefined ? {} : { priority: input.priority }),
|
|
56
|
+
...(input.parentTicketID === undefined ? {} : { parentTicketID: input.parentTicketID }),
|
|
57
|
+
...(input.dedupeKey === undefined ? {} : { dedupeKey: input.dedupeKey }),
|
|
58
|
+
});
|
|
59
|
+
const ticket = result.ticket;
|
|
60
|
+
const content = result.reused
|
|
61
|
+
? `工单 ${ticket.id} 已存在(reused,state=${ticket.state},priority=${ticket.priority}):${ticket.title}。dispatch 时带上 ticketID=${ticket.id}。`
|
|
62
|
+
: `工单 ${ticket.id} 已建立(state=${ticket.state},priority=${ticket.priority}):${ticket.title}。下一步 dispatch 时带上 ticketID=${ticket.id}。`;
|
|
63
|
+
return {
|
|
64
|
+
output: { ticketID: ticket.id, state: ticket.state, reused: result.reused },
|
|
65
|
+
content,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
const runTicketList = Effect.fn("superagent.ticketList")(function* (input, caller) {
|
|
69
|
+
yield* requireRouter(caller, "ticket_list");
|
|
70
|
+
const text = renderTicketList(listTickets({
|
|
71
|
+
...(input.threadID === undefined ? {} : { threadID: input.threadID }),
|
|
72
|
+
...(input.status === undefined ? {} : { status: input.status }),
|
|
73
|
+
...(input.since === undefined ? {} : { since: input.since }),
|
|
74
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
75
|
+
}));
|
|
76
|
+
return { output: { text }, content: text };
|
|
77
|
+
});
|
|
78
|
+
const runTicketUpdate = Effect.fn("superagent.ticketUpdate")(function* (ctx, input, caller) {
|
|
79
|
+
yield* requireRouter(caller, "ticket_update");
|
|
80
|
+
const ticket = yield* updateTicket(ctx, {
|
|
81
|
+
ticketID: input.ticketID,
|
|
82
|
+
...(input.status === undefined ? {} : { state: input.status }),
|
|
83
|
+
...(input.note === undefined ? {} : { note: input.note }),
|
|
84
|
+
});
|
|
85
|
+
const content = `工单 ${ticket.id} → ${ticket.state}:${ticket.title}`;
|
|
86
|
+
return { output: { ticketID: ticket.id, state: ticket.state, reused: false }, content };
|
|
87
|
+
});
|
|
88
|
+
const runTicketClose = Effect.fn("superagent.ticketClose")(function* (ctx, input, caller) {
|
|
89
|
+
yield* requireRouter(caller, "ticket_close");
|
|
90
|
+
const ticket = yield* closeTicket(ctx, {
|
|
91
|
+
ticketID: input.ticketID,
|
|
92
|
+
outcome: input.outcome,
|
|
93
|
+
summary: input.summary,
|
|
94
|
+
});
|
|
95
|
+
const content = `工单 ${ticket.id} 已结单(${ticket.state}):${ticket.closeReason ?? input.summary}`;
|
|
96
|
+
return { output: { ticketID: ticket.id, state: ticket.state, reused: false }, content };
|
|
97
|
+
});
|
|
98
|
+
/** RPC helper for the TUI: ready-to-render text, no router check (the user drove it). */
|
|
99
|
+
export function renderTicketsText(input = {}) {
|
|
100
|
+
return renderTicketList(listTickets({
|
|
101
|
+
...(input.threadID === undefined ? {} : { threadID: input.threadID }),
|
|
102
|
+
...(input.status === undefined ? {} : { status: input.status }),
|
|
103
|
+
...(input.since === undefined ? {} : { since: input.since }),
|
|
104
|
+
...(input.limit === undefined ? {} : { limit: input.limit }),
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
export function renderTicketText(ticketID) {
|
|
108
|
+
const ticket = getTicket(ticketID.trim());
|
|
109
|
+
if (ticket === undefined)
|
|
110
|
+
return `没有找到工单 ${ticketID}`;
|
|
111
|
+
return renderTicketDetail(ticket);
|
|
112
|
+
}
|
|
113
|
+
export const registerTicketTools = Effect.fn("superagent.registerTicketTools")(function* (ctx) {
|
|
114
|
+
yield* ctx.tool.transform((editor) => {
|
|
115
|
+
editor.add({
|
|
116
|
+
name: "ticket_create",
|
|
117
|
+
description: specFor("ticket_create").description,
|
|
118
|
+
input: TicketCreateInput,
|
|
119
|
+
output: TicketRefOutput,
|
|
120
|
+
options: { permission: toolPermission(specFor("ticket_create")) },
|
|
121
|
+
execute: (input, context) => runTicketCreate(ctx, input, context.sessionID),
|
|
122
|
+
});
|
|
123
|
+
editor.add({
|
|
124
|
+
name: "ticket_list",
|
|
125
|
+
description: specFor("ticket_list").description,
|
|
126
|
+
input: TicketListInput,
|
|
127
|
+
output: TicketTextOutput,
|
|
128
|
+
options: { permission: toolPermission(specFor("ticket_list")) },
|
|
129
|
+
execute: (input, context) => runTicketList(input, context.sessionID),
|
|
130
|
+
});
|
|
131
|
+
editor.add({
|
|
132
|
+
name: "ticket_update",
|
|
133
|
+
description: specFor("ticket_update").description,
|
|
134
|
+
input: TicketUpdateInput,
|
|
135
|
+
output: TicketRefOutput,
|
|
136
|
+
options: { permission: toolPermission(specFor("ticket_update")) },
|
|
137
|
+
execute: (input, context) => runTicketUpdate(ctx, input, context.sessionID),
|
|
138
|
+
});
|
|
139
|
+
editor.add({
|
|
140
|
+
name: "ticket_close",
|
|
141
|
+
description: specFor("ticket_close").description,
|
|
142
|
+
input: TicketCloseInput,
|
|
143
|
+
output: TicketRefOutput,
|
|
144
|
+
options: { permission: toolPermission(specFor("ticket_close")) },
|
|
145
|
+
execute: (input, context) => runTicketClose(ctx, input, context.sessionID),
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar view model for the ticket list.
|
|
3
|
+
*
|
|
4
|
+
* The `tickets` RPC returns `renderTicketList` text rather than structured
|
|
5
|
+
* rows (the server side is deployed separately and needs a service restart to
|
|
6
|
+
* change), so the TUI parses that text back into rows and re-lays them out for
|
|
7
|
+
* the fixed-width sidebar. Everything here is pure and width-aware, which
|
|
8
|
+
* keeps the OpenTUI component in `tui.tsx` to rendering only.
|
|
9
|
+
*
|
|
10
|
+
* `src/ticket.ts` is only imported for types: valuing `TICKET_STATES` here
|
|
11
|
+
* would pull the Effect/KV store into the TUI bundle. `test/ticket-view.test.ts`
|
|
12
|
+
* asserts the local copy stays in step with the real state list.
|
|
13
|
+
*/
|
|
14
|
+
import type { TicketPriority, TicketState } from "./ticket.js";
|
|
15
|
+
export type { TicketState };
|
|
16
|
+
export declare const TICKET_STATES: readonly ["open", "in_progress", "blocked", "done", "failed", "cancelled", "superseded"];
|
|
17
|
+
/**
|
|
18
|
+
* `SESSION_SIDEBAR_WIDTH` is 42 columns: 4 of horizontal padding plus the
|
|
19
|
+
* scrollbar column the sidebar reserves on the right leave 37 cells. The panel
|
|
20
|
+
* uses 36 and keeps one cell of slack, so a row can never push the sidebar
|
|
21
|
+
* into a horizontal scroll.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TICKET_PANEL_WIDTH = 36;
|
|
24
|
+
/** Titles are capped below the full remainder so a long one never pushes the id off-screen. */
|
|
25
|
+
export declare const TICKET_TITLE_MAX = 26;
|
|
26
|
+
/** Terminal tickets are collapsed; the panel is a glance, not an archive. */
|
|
27
|
+
export declare const TICKET_TERMINAL_LIMIT = 3;
|
|
28
|
+
export declare const TICKET_PANEL_LIMIT = 20;
|
|
29
|
+
export declare const TICKET_PANEL_REFRESH_MS = 4000;
|
|
30
|
+
export declare const TICKET_STATE_SYMBOL: Readonly<Record<TicketState, string>>;
|
|
31
|
+
export interface TicketRow {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly title: string;
|
|
34
|
+
readonly state: TicketState;
|
|
35
|
+
readonly priority: TicketPriority;
|
|
36
|
+
}
|
|
37
|
+
export interface TicketPanelItem {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly shortID: string;
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly state: TicketState;
|
|
42
|
+
}
|
|
43
|
+
export interface TicketPanel {
|
|
44
|
+
readonly total: number;
|
|
45
|
+
/** Tickets that are not closed yet, i.e. their count in the header. */
|
|
46
|
+
readonly active: number;
|
|
47
|
+
readonly items: ReadonlyArray<TicketPanelItem>;
|
|
48
|
+
/** Terminal tickets that did not fit under `TICKET_TERMINAL_LIMIT`. */
|
|
49
|
+
readonly hidden: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse `renderTicketList` output. Rows arrive grouped by state and already
|
|
53
|
+
* sorted newest-first inside a group, so the input order is the time order.
|
|
54
|
+
* Unrecognised lines are ignored: a future format change degrades the panel to
|
|
55
|
+
* "no tickets" instead of breaking the sidebar.
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseTicketList(text: string): Array<TicketRow>;
|
|
58
|
+
/** Truncate to `max` terminal cells (CJK counts as 2) with a trailing ellipsis. */
|
|
59
|
+
export declare function truncateCells(text: string, max: number): string;
|
|
60
|
+
export declare function shortTicketID(ticketID: string): string;
|
|
61
|
+
/** Order, truncate and collapse parsed rows into what the sidebar renders. */
|
|
62
|
+
export declare function ticketPanel(rows: ReadonlyArray<TicketRow>, width?: number, terminalLimit?: number): TicketPanel;
|