opencode-gateway 0.1.0 → 0.2.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 +26 -0
- package/dist/binding/gateway.d.ts +2 -1
- package/dist/binding/index.d.ts +1 -1
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/init.js +4 -1
- package/dist/cli/paths.js +1 -1
- package/dist/cli/templates.js +15 -0
- package/dist/cli.js +28 -4
- package/dist/config/gateway.d.ts +5 -0
- package/dist/config/gateway.js +6 -1
- package/dist/config/memory.d.ts +18 -0
- package/dist/config/memory.js +105 -0
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +5 -1
- package/dist/cron/runtime.d.ts +24 -5
- package/dist/cron/runtime.js +178 -13
- package/dist/delivery/text.js +1 -1
- package/dist/gateway.d.ts +3 -1
- package/dist/gateway.js +49 -37
- package/dist/host/logger.d.ts +8 -0
- package/dist/host/logger.js +53 -0
- package/dist/index.js +11 -7
- package/dist/memory/prompt.d.ts +9 -0
- package/dist/memory/prompt.js +122 -0
- package/dist/opencode/adapter.d.ts +2 -0
- package/dist/opencode/adapter.js +56 -7
- package/dist/runtime/conversation-coordinator.d.ts +4 -0
- package/dist/runtime/conversation-coordinator.js +22 -0
- package/dist/runtime/executor.d.ts +34 -5
- package/dist/runtime/executor.js +241 -22
- package/dist/runtime/runtime-singleton.d.ts +2 -0
- package/dist/runtime/runtime-singleton.js +28 -0
- package/dist/session/context.d.ts +1 -1
- package/dist/session/context.js +2 -23
- package/dist/session/system-prompt.d.ts +8 -0
- package/dist/session/system-prompt.js +52 -0
- package/dist/store/migrations.js +15 -1
- package/dist/store/sqlite.d.ts +20 -2
- package/dist/store/sqlite.js +103 -4
- package/dist/tools/channel-target.d.ts +5 -0
- package/dist/tools/channel-target.js +6 -0
- package/dist/tools/cron-run.js +1 -1
- package/dist/tools/cron-upsert.d.ts +2 -1
- package/dist/tools/cron-upsert.js +20 -6
- package/dist/tools/{cron-list.d.ts → schedule-cancel.d.ts} +1 -1
- package/dist/tools/schedule-cancel.js +12 -0
- package/dist/tools/schedule-format.d.ts +4 -0
- package/dist/tools/schedule-format.js +48 -0
- package/dist/tools/{cron-remove.d.ts → schedule-list.d.ts} +1 -1
- package/dist/tools/schedule-list.js +17 -0
- package/dist/tools/schedule-once.d.ts +4 -0
- package/dist/tools/schedule-once.js +43 -0
- package/dist/tools/schedule-status.d.ts +3 -0
- package/dist/tools/schedule-status.js +23 -0
- package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
- package/package.json +4 -4
- package/dist/host/noop.d.ts +0 -4
- package/dist/host/noop.js +0 -14
- package/dist/tools/cron-list.js +0 -34
- package/dist/tools/cron-remove.js +0 -12
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, relative } from "node:path";
|
|
3
|
+
const MARKDOWN_GLOBS = ["**/*.md", "**/*.markdown"];
|
|
4
|
+
const UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
5
|
+
export class GatewayMemoryPromptProvider {
|
|
6
|
+
config;
|
|
7
|
+
logger;
|
|
8
|
+
constructor(config, logger) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
}
|
|
12
|
+
async buildPrompt() {
|
|
13
|
+
if (this.config.entries.length === 0) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const sections = await Promise.all(this.config.entries.map((entry) => this.buildEntrySection(entry)));
|
|
17
|
+
return ["Gateway memory:", ...sections].join("\n\n");
|
|
18
|
+
}
|
|
19
|
+
async buildEntrySection(entry) {
|
|
20
|
+
const lines = [`Configured path: ${entry.displayPath}`, `Description: ${entry.description}`];
|
|
21
|
+
const injectedFiles = await collectInjectedFiles(entry, this.logger);
|
|
22
|
+
for (const file of injectedFiles) {
|
|
23
|
+
lines.push("");
|
|
24
|
+
lines.push(`File: ${file.displayPath}`);
|
|
25
|
+
lines.push(codeFence(file.infoString, file.text));
|
|
26
|
+
}
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function collectInjectedFiles(entry, logger) {
|
|
31
|
+
if (entry.kind === "file") {
|
|
32
|
+
if (!entry.injectContent) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const text = await readTextFile(entry.path, logger);
|
|
36
|
+
if (text === null) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
displayPath: entry.displayPath,
|
|
42
|
+
infoString: inferFenceInfoString(entry.path),
|
|
43
|
+
text,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
const filePaths = new Set();
|
|
48
|
+
if (entry.injectMarkdownContents) {
|
|
49
|
+
for (const pattern of MARKDOWN_GLOBS) {
|
|
50
|
+
addMatchingFiles(filePaths, entry.path, pattern);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const pattern of entry.globs) {
|
|
54
|
+
addMatchingFiles(filePaths, entry.path, pattern);
|
|
55
|
+
}
|
|
56
|
+
const injectedFiles = [];
|
|
57
|
+
for (const filePath of [...filePaths].sort((left, right) => left.localeCompare(right))) {
|
|
58
|
+
const text = await readTextFile(filePath, logger);
|
|
59
|
+
if (text === null) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
injectedFiles.push({
|
|
63
|
+
displayPath: relativeDisplayPath(entry.path, entry.displayPath, filePath),
|
|
64
|
+
infoString: inferFenceInfoString(filePath),
|
|
65
|
+
text,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return injectedFiles;
|
|
69
|
+
}
|
|
70
|
+
function addMatchingFiles(result, cwd, pattern) {
|
|
71
|
+
const glob = new Bun.Glob(pattern);
|
|
72
|
+
for (const match of glob.scanSync({ cwd, absolute: true, onlyFiles: true })) {
|
|
73
|
+
result.add(match);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function readTextFile(path, logger) {
|
|
77
|
+
let bytes;
|
|
78
|
+
try {
|
|
79
|
+
bytes = await readFile(path);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
logger.log("warn", `memory file could not be read and will be skipped: ${path}: ${formatError(error)}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = UTF8_TEXT_DECODER.decode(bytes);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
logger.log("warn", `memory file is not valid UTF-8 and will be skipped: ${path}`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (text.includes("\u0000")) {
|
|
94
|
+
logger.log("warn", `memory file looks binary and will be skipped: ${path}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
function relativeDisplayPath(rootPath, rootDisplayPath, filePath) {
|
|
100
|
+
const suffix = relative(rootPath, filePath);
|
|
101
|
+
if (suffix.length === 0) {
|
|
102
|
+
return rootDisplayPath;
|
|
103
|
+
}
|
|
104
|
+
return `${rootDisplayPath}/${suffix.replaceAll("\\", "/")}`;
|
|
105
|
+
}
|
|
106
|
+
function inferFenceInfoString(path) {
|
|
107
|
+
const extension = extname(path).slice(1).toLowerCase();
|
|
108
|
+
if (!/^[a-z0-9_+-]+$/.test(extension)) {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
return extension;
|
|
112
|
+
}
|
|
113
|
+
function codeFence(infoString, text) {
|
|
114
|
+
const language = infoString.length === 0 ? "" : infoString;
|
|
115
|
+
return [`\`\`\`${language}`, text, "```"].join("\n");
|
|
116
|
+
}
|
|
117
|
+
function formatError(error) {
|
|
118
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
119
|
+
return error.message;
|
|
120
|
+
}
|
|
121
|
+
return String(error);
|
|
122
|
+
}
|
|
@@ -6,6 +6,8 @@ export declare class OpencodeSdkAdapter {
|
|
|
6
6
|
private readonly directory;
|
|
7
7
|
constructor(client: OpencodeClient, directory: string);
|
|
8
8
|
createFreshSession(title: string): Promise<string>;
|
|
9
|
+
isSessionBusy(sessionId: string): Promise<boolean>;
|
|
10
|
+
abortSession(sessionId: string): Promise<void>;
|
|
9
11
|
execute(command: BindingOpencodeCommand): Promise<BindingOpencodeCommandResult>;
|
|
10
12
|
private lookupSession;
|
|
11
13
|
private createSession;
|
package/dist/opencode/adapter.js
CHANGED
|
@@ -18,6 +18,31 @@ export class OpencodeSdkAdapter {
|
|
|
18
18
|
});
|
|
19
19
|
return unwrapData(session).id;
|
|
20
20
|
}
|
|
21
|
+
async isSessionBusy(sessionId) {
|
|
22
|
+
const statuses = await this.client.session.status({
|
|
23
|
+
query: { directory: this.directory },
|
|
24
|
+
responseStyle: "data",
|
|
25
|
+
throwOnError: true,
|
|
26
|
+
});
|
|
27
|
+
const current = unwrapData(statuses)[sessionId];
|
|
28
|
+
return current?.type === "busy";
|
|
29
|
+
}
|
|
30
|
+
async abortSession(sessionId) {
|
|
31
|
+
try {
|
|
32
|
+
await this.client.session.abort({
|
|
33
|
+
path: { id: sessionId },
|
|
34
|
+
query: { directory: this.directory },
|
|
35
|
+
responseStyle: "data",
|
|
36
|
+
throwOnError: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (isMissingSessionError(error)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
21
46
|
async execute(command) {
|
|
22
47
|
try {
|
|
23
48
|
switch (command.kind) {
|
|
@@ -125,6 +150,7 @@ export class OpencodeSdkAdapter {
|
|
|
125
150
|
}
|
|
126
151
|
async awaitPromptResponse(command) {
|
|
127
152
|
const deadline = Date.now() + PROMPT_RESPONSE_TIMEOUT_MS;
|
|
153
|
+
let stableCandidateKey = null;
|
|
128
154
|
for (;;) {
|
|
129
155
|
const messages = await this.client.session.messages({
|
|
130
156
|
path: { id: command.sessionId },
|
|
@@ -137,12 +163,19 @@ export class OpencodeSdkAdapter {
|
|
|
137
163
|
});
|
|
138
164
|
const response = selectAssistantResponse(unwrapData(messages), command.messageId);
|
|
139
165
|
if (response !== null) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
const candidateKey = createAssistantCandidateKey(response);
|
|
167
|
+
if (stableCandidateKey === candidateKey) {
|
|
168
|
+
return {
|
|
169
|
+
kind: "awaitPromptResponse",
|
|
170
|
+
sessionId: command.sessionId,
|
|
171
|
+
messageId: response.info.id,
|
|
172
|
+
parts: response.parts.flatMap(toBindingMessagePart),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
stableCandidateKey = candidateKey;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
stableCandidateKey = null;
|
|
146
179
|
}
|
|
147
180
|
if (Date.now() >= deadline) {
|
|
148
181
|
throw new Error(`assistant message for prompt ${command.messageId} is unavailable after prompt completion`);
|
|
@@ -221,6 +254,19 @@ function selectAssistantResponse(messages, userMessageId) {
|
|
|
221
254
|
}
|
|
222
255
|
return null;
|
|
223
256
|
}
|
|
257
|
+
function createAssistantCandidateKey(message) {
|
|
258
|
+
return JSON.stringify({
|
|
259
|
+
messageId: message.info.id,
|
|
260
|
+
finish: message.info.finish ?? null,
|
|
261
|
+
hasError: message.info.error !== undefined,
|
|
262
|
+
parts: message.parts.map((part) => ({
|
|
263
|
+
id: part.id ?? null,
|
|
264
|
+
type: part.type,
|
|
265
|
+
text: typeof part.text === "string" ? part.text : null,
|
|
266
|
+
ignored: part.ignored === true,
|
|
267
|
+
})),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
224
270
|
function isAssistantChildMessage(userMessageId) {
|
|
225
271
|
return (message) => message.info?.role === "assistant" && message.info.parentID === userMessageId;
|
|
226
272
|
}
|
|
@@ -239,7 +285,10 @@ function toBindingMessagePart(part) {
|
|
|
239
285
|
];
|
|
240
286
|
}
|
|
241
287
|
function hasVisibleText(message) {
|
|
242
|
-
return message.parts.some((part) => part.type === "text" &&
|
|
288
|
+
return message.parts.some((part) => part.type === "text" &&
|
|
289
|
+
part.ignored !== true &&
|
|
290
|
+
typeof part.text === "string" &&
|
|
291
|
+
part.text.trim().length > 0);
|
|
243
292
|
}
|
|
244
293
|
function toBindingMessage(message) {
|
|
245
294
|
if (typeof message.info?.id !== "string" ||
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class ConversationCoordinator {
|
|
2
|
+
tails = new Map();
|
|
3
|
+
async runExclusive(conversationKey, operation) {
|
|
4
|
+
const previous = this.tails.get(conversationKey) ?? Promise.resolve();
|
|
5
|
+
let release;
|
|
6
|
+
const current = new Promise((resolve) => {
|
|
7
|
+
release = resolve;
|
|
8
|
+
});
|
|
9
|
+
const tail = previous.then(() => current, () => current);
|
|
10
|
+
this.tails.set(conversationKey, tail);
|
|
11
|
+
await previous;
|
|
12
|
+
try {
|
|
13
|
+
return await operation();
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
release();
|
|
17
|
+
if (this.tails.get(conversationKey) === tail) {
|
|
18
|
+
this.tails.delete(conversationKey);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { BindingCronJobSpec, BindingInboundMessage, BindingLoggerHost, BindingPreparedExecution, BindingRuntimeReport, GatewayBindingModule } from "../binding";
|
|
1
|
+
import type { BindingCronJobSpec, BindingDeliveryTarget, BindingInboundMessage, BindingLoggerHost, BindingPreparedExecution, BindingRuntimeReport, GatewayBindingModule } from "../binding";
|
|
2
2
|
import type { GatewayTextDelivery } from "../delivery/text";
|
|
3
3
|
import type { OpencodeSdkAdapter } from "../opencode/adapter";
|
|
4
4
|
import type { OpencodeEventHub } from "../opencode/events";
|
|
5
5
|
import type { MailboxEntryRecord, SqliteStore } from "../store/sqlite";
|
|
6
|
+
import { ConversationCoordinator } from "./conversation-coordinator";
|
|
6
7
|
export declare class GatewayExecutor {
|
|
7
8
|
private readonly module;
|
|
8
9
|
private readonly store;
|
|
@@ -10,15 +11,43 @@ export declare class GatewayExecutor {
|
|
|
10
11
|
private readonly events;
|
|
11
12
|
private readonly delivery;
|
|
12
13
|
private readonly logger;
|
|
13
|
-
|
|
14
|
+
private readonly coordinator;
|
|
15
|
+
private internalPromptSequence;
|
|
16
|
+
constructor(module: GatewayBindingModule, store: SqliteStore, opencode: GatewayOpencodeRuntimeLike, events: OpencodeEventHub, delivery: GatewayTextDeliveryLike, logger: BindingLoggerHost, coordinator?: ConversationCoordinator);
|
|
14
17
|
prepareInboundMessage(message: BindingInboundMessage): BindingPreparedExecution;
|
|
15
18
|
handleInboundMessage(message: BindingInboundMessage): Promise<BindingRuntimeReport>;
|
|
16
19
|
executeMailboxEntries(entries: MailboxEntryRecord[]): Promise<BindingRuntimeReport>;
|
|
17
20
|
dispatchCronJob(job: BindingCronJobSpec): Promise<BindingRuntimeReport>;
|
|
18
|
-
|
|
21
|
+
dispatchScheduledJob(input: DispatchScheduledJobInput): Promise<BindingRuntimeReport>;
|
|
22
|
+
appendContextToConversation(input: AppendContextToConversationInput): Promise<void>;
|
|
23
|
+
private executePreparedBatch;
|
|
24
|
+
private ensureConversationSession;
|
|
25
|
+
private initializeReplyTargetsIfMissing;
|
|
26
|
+
private preparePersistedSessionForPrompt;
|
|
27
|
+
private lookupSession;
|
|
28
|
+
private createSession;
|
|
29
|
+
private waitUntilIdle;
|
|
30
|
+
private appendPrompt;
|
|
31
|
+
private cleanupResidualBusySession;
|
|
32
|
+
private waitForSessionToSettle;
|
|
33
|
+
private abortSessionAndWaitForSettle;
|
|
34
|
+
private createInternalPromptIdentity;
|
|
19
35
|
private executeDriver;
|
|
20
36
|
}
|
|
21
|
-
export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "executeMailboxEntries" | "prepareInboundMessage">;
|
|
37
|
+
export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "dispatchScheduledJob" | "appendContextToConversation" | "executeMailboxEntries" | "prepareInboundMessage">;
|
|
38
|
+
export type DispatchScheduledJobInput = {
|
|
39
|
+
jobId: string;
|
|
40
|
+
jobKind: "cron" | "once";
|
|
41
|
+
conversationKey: string;
|
|
42
|
+
prompt: string;
|
|
43
|
+
replyTarget: BindingPreparedExecution["replyTarget"];
|
|
44
|
+
};
|
|
45
|
+
export type AppendContextToConversationInput = {
|
|
46
|
+
conversationKey: string;
|
|
47
|
+
replyTarget: BindingDeliveryTarget | null;
|
|
48
|
+
body: string;
|
|
49
|
+
recordedAtMs: number;
|
|
50
|
+
};
|
|
22
51
|
type GatewayTextDeliveryLike = Pick<GatewayTextDelivery, "openMany">;
|
|
23
|
-
type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute">;
|
|
52
|
+
type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute" | "isSessionBusy" | "abortSession">;
|
|
24
53
|
export {};
|
package/dist/runtime/executor.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { ConversationCoordinator } from "./conversation-coordinator";
|
|
1
2
|
import { runOpencodeDriver } from "./opencode-runner";
|
|
3
|
+
const SESSION_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
|
4
|
+
const SESSION_ABORT_POLL_MS = 250;
|
|
5
|
+
const SESSION_RESIDUAL_BUSY_GRACE_POLLS = 3;
|
|
2
6
|
export class GatewayExecutor {
|
|
3
7
|
module;
|
|
4
8
|
store;
|
|
@@ -6,13 +10,16 @@ export class GatewayExecutor {
|
|
|
6
10
|
events;
|
|
7
11
|
delivery;
|
|
8
12
|
logger;
|
|
9
|
-
|
|
13
|
+
coordinator;
|
|
14
|
+
internalPromptSequence = 0;
|
|
15
|
+
constructor(module, store, opencode, events, delivery, logger, coordinator = new ConversationCoordinator()) {
|
|
10
16
|
this.module = module;
|
|
11
17
|
this.store = store;
|
|
12
18
|
this.opencode = opencode;
|
|
13
19
|
this.events = events;
|
|
14
20
|
this.delivery = delivery;
|
|
15
21
|
this.logger = logger;
|
|
22
|
+
this.coordinator = coordinator;
|
|
16
23
|
}
|
|
17
24
|
prepareInboundMessage(message) {
|
|
18
25
|
return this.module.prepareInboundExecution(message);
|
|
@@ -52,19 +59,21 @@ export class GatewayExecutor {
|
|
|
52
59
|
}
|
|
53
60
|
const recordedAtMs = Date.now();
|
|
54
61
|
this.logger.log("info", "handling inbound gateway message");
|
|
55
|
-
this.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (const entry of preparedEntries) {
|
|
60
|
-
this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
|
|
61
|
-
deliveryTarget: entry.prepared.replyTarget,
|
|
62
|
-
sender: entry.message.sender,
|
|
63
|
-
text: entry.message.text,
|
|
64
|
-
attachments: entry.message.attachments,
|
|
62
|
+
return await this.coordinator.runExclusive(conversationKey, async () => {
|
|
63
|
+
this.store.appendJournal(createJournalEntry("mailbox_flush", recordedAtMs, conversationKey, {
|
|
64
|
+
entryIds: preparedEntries.map((entry) => entry.entry.id),
|
|
65
|
+
count: preparedEntries.length,
|
|
65
66
|
}));
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
for (const entry of preparedEntries) {
|
|
68
|
+
this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
|
|
69
|
+
deliveryTarget: entry.prepared.replyTarget,
|
|
70
|
+
sender: entry.message.sender,
|
|
71
|
+
text: entry.message.text,
|
|
72
|
+
attachments: entry.message.attachments,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
return await this.executePreparedBatch(preparedEntries, recordedAtMs);
|
|
76
|
+
});
|
|
68
77
|
}
|
|
69
78
|
async dispatchCronJob(job) {
|
|
70
79
|
const prepared = this.module.prepareCronExecution(job);
|
|
@@ -80,20 +89,61 @@ export class GatewayExecutor {
|
|
|
80
89
|
deliveryTarget: prepared.replyTarget?.target ?? null,
|
|
81
90
|
deliveryTopic: prepared.replyTarget?.topic ?? null,
|
|
82
91
|
}));
|
|
83
|
-
return await this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
|
|
93
|
+
return await this.executePreparedBatch([
|
|
94
|
+
{
|
|
95
|
+
entry: null,
|
|
96
|
+
message: null,
|
|
97
|
+
prepared,
|
|
98
|
+
},
|
|
99
|
+
], recordedAtMs);
|
|
100
|
+
});
|
|
90
101
|
}
|
|
91
|
-
async
|
|
102
|
+
async dispatchScheduledJob(input) {
|
|
103
|
+
const prepared = prepareTextExecution(input.conversationKey, input.prompt, input.replyTarget);
|
|
104
|
+
const recordedAtMs = Date.now();
|
|
105
|
+
this.logger.log("info", "dispatching scheduled gateway job");
|
|
106
|
+
return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
|
|
107
|
+
this.store.appendJournal(createJournalEntry("cron_dispatch", recordedAtMs, prepared.conversationKey, {
|
|
108
|
+
id: input.jobId,
|
|
109
|
+
kind: input.jobKind,
|
|
110
|
+
promptParts: prepared.promptParts,
|
|
111
|
+
deliveryChannel: prepared.replyTarget?.channel ?? null,
|
|
112
|
+
deliveryTarget: prepared.replyTarget?.target ?? null,
|
|
113
|
+
deliveryTopic: prepared.replyTarget?.topic ?? null,
|
|
114
|
+
}));
|
|
115
|
+
return await this.executePreparedBatch([
|
|
116
|
+
{
|
|
117
|
+
entry: null,
|
|
118
|
+
message: null,
|
|
119
|
+
prepared,
|
|
120
|
+
},
|
|
121
|
+
], recordedAtMs);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async appendContextToConversation(input) {
|
|
125
|
+
const conversationKey = normalizeRequiredField(input.conversationKey, "conversation key");
|
|
126
|
+
const body = normalizeRequiredField(input.body, "context body");
|
|
127
|
+
await this.coordinator.runExclusive(conversationKey, async () => {
|
|
128
|
+
const sessionId = await this.ensureConversationSession(conversationKey, input.recordedAtMs, input.replyTarget === null ? [] : [input.replyTarget]);
|
|
129
|
+
const promptIdentity = this.createInternalPromptIdentity("context", input.recordedAtMs);
|
|
130
|
+
await this.appendPrompt(sessionId, promptIdentity.messageId, [
|
|
131
|
+
{
|
|
132
|
+
kind: "text",
|
|
133
|
+
partId: promptIdentity.partId,
|
|
134
|
+
text: body,
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async executePreparedBatch(entries, recordedAtMs) {
|
|
92
140
|
const conversationKey = entries[0].prepared.conversationKey;
|
|
93
141
|
const persistedSessionId = this.store.getSessionBinding(conversationKey);
|
|
142
|
+
const preparedSessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
|
|
94
143
|
const replyTargets = dedupeReplyTargets(entries.flatMap((entry) => (entry.prepared.replyTarget === null ? [] : [entry.prepared.replyTarget])));
|
|
95
144
|
const [deliverySession] = replyTargets.length === 0 ? [null] : await this.delivery.openMany(replyTargets, "auto");
|
|
96
|
-
const promptResult = await this.executeDriver(entries, recordedAtMs,
|
|
145
|
+
const promptResult = await this.executeDriver(entries, recordedAtMs, preparedSessionId, deliverySession, replyTargets);
|
|
146
|
+
await this.cleanupResidualBusySession(promptResult.sessionId);
|
|
97
147
|
this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, promptResult.sessionId, recordedAtMs);
|
|
98
148
|
let delivered = false;
|
|
99
149
|
if (deliverySession !== null) {
|
|
@@ -112,6 +162,139 @@ export class GatewayExecutor {
|
|
|
112
162
|
recordedAtMs: BigInt(recordedAtMs),
|
|
113
163
|
};
|
|
114
164
|
}
|
|
165
|
+
async ensureConversationSession(conversationKey, recordedAtMs, replyTargets) {
|
|
166
|
+
const persistedSessionId = this.store.getSessionBinding(conversationKey);
|
|
167
|
+
let sessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
|
|
168
|
+
let retryMissingSession = true;
|
|
169
|
+
for (;;) {
|
|
170
|
+
if (sessionId === null) {
|
|
171
|
+
sessionId = await this.createSession(conversationKey);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
await this.waitUntilIdle(sessionId);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (retryMissingSession && error instanceof MissingSessionCommandError) {
|
|
179
|
+
retryMissingSession = false;
|
|
180
|
+
sessionId = null;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, sessionId, recordedAtMs);
|
|
187
|
+
this.initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs);
|
|
188
|
+
return sessionId;
|
|
189
|
+
}
|
|
190
|
+
initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs) {
|
|
191
|
+
if (replyTargets.length === 0 || this.store.listSessionReplyTargets(sessionId).length > 0) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.store.replaceSessionReplyTargets({
|
|
195
|
+
sessionId,
|
|
196
|
+
conversationKey,
|
|
197
|
+
targets: replyTargets,
|
|
198
|
+
recordedAtMs,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async preparePersistedSessionForPrompt(sessionId) {
|
|
202
|
+
if (sessionId === null) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const found = await this.lookupSession(sessionId);
|
|
206
|
+
if (!found) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (!(await this.opencode.isSessionBusy(sessionId))) {
|
|
210
|
+
return sessionId;
|
|
211
|
+
}
|
|
212
|
+
this.logger.log("warn", `aborting busy gateway session before prompt dispatch: ${sessionId}`);
|
|
213
|
+
try {
|
|
214
|
+
await this.abortSessionAndWaitForSettle(sessionId);
|
|
215
|
+
return sessionId;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
this.logger.log("warn", `busy gateway session did not settle and will be replaced: ${sessionId}: ${extractErrorMessage(error)}`);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async lookupSession(sessionId) {
|
|
223
|
+
const result = await this.opencode.execute({
|
|
224
|
+
kind: "lookupSession",
|
|
225
|
+
sessionId,
|
|
226
|
+
});
|
|
227
|
+
const lookup = expectCommandResult(result, "lookupSession");
|
|
228
|
+
return lookup.found;
|
|
229
|
+
}
|
|
230
|
+
async createSession(conversationKey) {
|
|
231
|
+
const result = await this.opencode.execute({
|
|
232
|
+
kind: "createSession",
|
|
233
|
+
title: `Gateway ${conversationKey}`,
|
|
234
|
+
});
|
|
235
|
+
return expectCommandResult(result, "createSession").sessionId;
|
|
236
|
+
}
|
|
237
|
+
async waitUntilIdle(sessionId) {
|
|
238
|
+
const result = await this.opencode.execute({
|
|
239
|
+
kind: "waitUntilIdle",
|
|
240
|
+
sessionId,
|
|
241
|
+
});
|
|
242
|
+
expectCommandResult(result, "waitUntilIdle");
|
|
243
|
+
}
|
|
244
|
+
async appendPrompt(sessionId, messageId, parts) {
|
|
245
|
+
const result = await this.opencode.execute({
|
|
246
|
+
kind: "appendPrompt",
|
|
247
|
+
sessionId,
|
|
248
|
+
messageId,
|
|
249
|
+
parts,
|
|
250
|
+
});
|
|
251
|
+
expectCommandResult(result, "appendPrompt");
|
|
252
|
+
}
|
|
253
|
+
async cleanupResidualBusySession(sessionId) {
|
|
254
|
+
if (await this.waitForSessionToSettle(sessionId, SESSION_RESIDUAL_BUSY_GRACE_POLLS)) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.logger.log("debug", `aborting residual busy gateway session after prompt completion: ${sessionId}`);
|
|
258
|
+
try {
|
|
259
|
+
await this.abortSessionAndWaitForSettle(sessionId);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
this.logger.log("warn", `residual busy gateway session did not settle after abort: ${sessionId}: ${extractErrorMessage(error)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async waitForSessionToSettle(sessionId, extraPolls) {
|
|
266
|
+
for (let attempt = 0; attempt <= extraPolls; attempt += 1) {
|
|
267
|
+
if (!(await this.opencode.isSessionBusy(sessionId))) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (attempt < extraPolls) {
|
|
271
|
+
await Bun.sleep(SESSION_ABORT_POLL_MS);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
async abortSessionAndWaitForSettle(sessionId) {
|
|
277
|
+
await this.opencode.abortSession(sessionId);
|
|
278
|
+
const deadline = Date.now() + SESSION_ABORT_SETTLE_TIMEOUT_MS;
|
|
279
|
+
for (;;) {
|
|
280
|
+
if (!(await this.opencode.isSessionBusy(sessionId))) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (Date.now() >= deadline) {
|
|
284
|
+
throw new Error(`session remained busy after abort for ${SESSION_ABORT_SETTLE_TIMEOUT_MS}ms`);
|
|
285
|
+
}
|
|
286
|
+
await Bun.sleep(SESSION_ABORT_POLL_MS);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
createInternalPromptIdentity(prefix, recordedAtMs) {
|
|
290
|
+
const suffix = `${recordedAtMs}_${this.internalPromptSequence}`;
|
|
291
|
+
this.internalPromptSequence += 1;
|
|
292
|
+
const normalizedPrefix = prefix.replaceAll(":", "_");
|
|
293
|
+
return {
|
|
294
|
+
messageId: `msg_gateway_${normalizedPrefix}_${suffix}`,
|
|
295
|
+
partId: `prt_gateway_${normalizedPrefix}_${suffix}_0`,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
115
298
|
async executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets) {
|
|
116
299
|
return await runOpencodeDriver({
|
|
117
300
|
module: this.module,
|
|
@@ -143,6 +326,18 @@ function createJournalEntry(kind, recordedAtMs, conversationKey, payload) {
|
|
|
143
326
|
payload,
|
|
144
327
|
};
|
|
145
328
|
}
|
|
329
|
+
function prepareTextExecution(conversationKey, prompt, replyTarget) {
|
|
330
|
+
return {
|
|
331
|
+
conversationKey: normalizeRequiredField(conversationKey, "conversation key"),
|
|
332
|
+
promptParts: [
|
|
333
|
+
{
|
|
334
|
+
kind: "text",
|
|
335
|
+
text: normalizeRequiredField(prompt, "schedule prompt"),
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
replyTarget,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
146
341
|
function mailboxEntryToInboundMessage(entry) {
|
|
147
342
|
return {
|
|
148
343
|
deliveryTarget: {
|
|
@@ -174,6 +369,30 @@ function normalizeRequiredField(value, field) {
|
|
|
174
369
|
}
|
|
175
370
|
return trimmed;
|
|
176
371
|
}
|
|
372
|
+
function extractErrorMessage(error) {
|
|
373
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
374
|
+
return error.message;
|
|
375
|
+
}
|
|
376
|
+
return String(error);
|
|
377
|
+
}
|
|
378
|
+
function expectCommandResult(result, expectedKind) {
|
|
379
|
+
if (result.kind === "error") {
|
|
380
|
+
if (result.code === "missingSession") {
|
|
381
|
+
throw new MissingSessionCommandError(result.message);
|
|
382
|
+
}
|
|
383
|
+
throw new Error(result.message);
|
|
384
|
+
}
|
|
385
|
+
if (result.kind !== expectedKind) {
|
|
386
|
+
throw new Error(`unexpected OpenCode result kind: expected ${expectedKind}, received ${result.kind}`);
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
class MissingSessionCommandError extends Error {
|
|
391
|
+
constructor(message) {
|
|
392
|
+
super(message);
|
|
393
|
+
this.name = "MissingSessionCommandError";
|
|
394
|
+
}
|
|
395
|
+
}
|
|
177
396
|
function createPromptKey(entry, recordedAtMs, index) {
|
|
178
397
|
if (entry.entry !== null) {
|
|
179
398
|
return `mailbox:${entry.entry.id}:${recordedAtMs}`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const RUNTIME_CACHE_KEY = Symbol.for("opencode-gateway.runtime-cache");
|
|
2
|
+
export function getOrCreateRuntimeSingleton(key, factory) {
|
|
3
|
+
const cache = getRuntimeCache();
|
|
4
|
+
const existing = cache.get(key);
|
|
5
|
+
if (existing !== undefined) {
|
|
6
|
+
return existing;
|
|
7
|
+
}
|
|
8
|
+
const promise = factory().catch((error) => {
|
|
9
|
+
if (cache.get(key) === promise) {
|
|
10
|
+
cache.delete(key);
|
|
11
|
+
}
|
|
12
|
+
throw error;
|
|
13
|
+
});
|
|
14
|
+
cache.set(key, promise);
|
|
15
|
+
return promise;
|
|
16
|
+
}
|
|
17
|
+
export function clearRuntimeSingletonForTests(key) {
|
|
18
|
+
getRuntimeCache().delete(key);
|
|
19
|
+
}
|
|
20
|
+
function getRuntimeCache() {
|
|
21
|
+
const globalScope = globalThis;
|
|
22
|
+
let cache = globalScope[RUNTIME_CACHE_KEY];
|
|
23
|
+
if (cache === undefined) {
|
|
24
|
+
cache = new Map();
|
|
25
|
+
globalScope[RUNTIME_CACHE_KEY] = cache;
|
|
26
|
+
}
|
|
27
|
+
return cache;
|
|
28
|
+
}
|
|
@@ -6,5 +6,5 @@ export declare class GatewaySessionContext {
|
|
|
6
6
|
replaceReplyTargets(sessionId: string, conversationKey: string, targets: BindingDeliveryTarget[], recordedAtMs: number): void;
|
|
7
7
|
listReplyTargets(sessionId: string): BindingDeliveryTarget[];
|
|
8
8
|
getDefaultReplyTarget(sessionId: string): BindingDeliveryTarget | null;
|
|
9
|
-
|
|
9
|
+
isGatewaySession(sessionId: string): boolean;
|
|
10
10
|
}
|