opencode-gateway 0.2.3 → 0.2.4

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.
Files changed (75) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/index.js +20907 -52
  3. package/package.json +1 -1
  4. package/dist/binding/execution.js +0 -1
  5. package/dist/binding/gateway.js +0 -1
  6. package/dist/binding/index.js +0 -4
  7. package/dist/binding/opencode.js +0 -1
  8. package/dist/cli/args.js +0 -53
  9. package/dist/cli/doctor.js +0 -49
  10. package/dist/cli/init.js +0 -40
  11. package/dist/cli/opencode-config-file.js +0 -18
  12. package/dist/cli/opencode-config.js +0 -194
  13. package/dist/cli/paths.js +0 -22
  14. package/dist/cli/templates.js +0 -41
  15. package/dist/config/cron.js +0 -52
  16. package/dist/config/gateway.js +0 -148
  17. package/dist/config/memory.js +0 -105
  18. package/dist/config/paths.js +0 -39
  19. package/dist/config/telegram.js +0 -91
  20. package/dist/cron/runtime.js +0 -402
  21. package/dist/delivery/telegram.js +0 -75
  22. package/dist/delivery/text.js +0 -175
  23. package/dist/gateway.js +0 -117
  24. package/dist/host/file-sender.js +0 -59
  25. package/dist/host/logger.js +0 -53
  26. package/dist/host/transport.js +0 -35
  27. package/dist/mailbox/router.js +0 -16
  28. package/dist/media/mime.js +0 -45
  29. package/dist/memory/prompt.js +0 -122
  30. package/dist/opencode/adapter.js +0 -340
  31. package/dist/opencode/driver-hub.js +0 -82
  32. package/dist/opencode/event-normalize.js +0 -48
  33. package/dist/opencode/event-stream.js +0 -65
  34. package/dist/opencode/events.js +0 -1
  35. package/dist/questions/client.js +0 -36
  36. package/dist/questions/format.js +0 -36
  37. package/dist/questions/normalize.js +0 -45
  38. package/dist/questions/parser.js +0 -96
  39. package/dist/questions/runtime.js +0 -195
  40. package/dist/questions/types.js +0 -1
  41. package/dist/runtime/attachments.js +0 -12
  42. package/dist/runtime/conversation-coordinator.js +0 -22
  43. package/dist/runtime/executor.js +0 -407
  44. package/dist/runtime/mailbox.js +0 -112
  45. package/dist/runtime/opencode-runner.js +0 -79
  46. package/dist/runtime/runtime-singleton.js +0 -28
  47. package/dist/session/context.js +0 -23
  48. package/dist/session/conversation-key.js +0 -3
  49. package/dist/session/switcher.js +0 -59
  50. package/dist/session/system-prompt.js +0 -52
  51. package/dist/store/migrations.js +0 -197
  52. package/dist/store/sqlite.js +0 -777
  53. package/dist/telegram/client.js +0 -180
  54. package/dist/telegram/media.js +0 -65
  55. package/dist/telegram/normalize.js +0 -119
  56. package/dist/telegram/poller.js +0 -166
  57. package/dist/telegram/runtime.js +0 -157
  58. package/dist/telegram/state.js +0 -149
  59. package/dist/telegram/types.js +0 -1
  60. package/dist/tools/channel-new-session.js +0 -27
  61. package/dist/tools/channel-send-file.js +0 -27
  62. package/dist/tools/channel-target.js +0 -34
  63. package/dist/tools/cron-run.js +0 -20
  64. package/dist/tools/cron-upsert.js +0 -51
  65. package/dist/tools/gateway-dispatch-cron.js +0 -33
  66. package/dist/tools/gateway-status.js +0 -25
  67. package/dist/tools/schedule-cancel.js +0 -12
  68. package/dist/tools/schedule-format.js +0 -48
  69. package/dist/tools/schedule-list.js +0 -17
  70. package/dist/tools/schedule-once.js +0 -43
  71. package/dist/tools/schedule-status.js +0 -23
  72. package/dist/tools/telegram-send-test.js +0 -26
  73. package/dist/tools/telegram-status.js +0 -49
  74. package/dist/tools/time.js +0 -25
  75. package/dist/utils/error.js +0 -57
@@ -1,105 +0,0 @@
1
- import { stat } from "node:fs/promises";
2
- import { resolve } from "node:path";
3
- export async function parseMemoryConfig(value, workspaceDirPath) {
4
- const table = readMemoryTable(value);
5
- const entries = await readMemoryEntries(table.entries, workspaceDirPath);
6
- return { entries };
7
- }
8
- function readMemoryTable(value) {
9
- if (value === undefined) {
10
- return {};
11
- }
12
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
13
- throw new Error("memory must be a table when present");
14
- }
15
- return value;
16
- }
17
- async function readMemoryEntries(value, workspaceDirPath) {
18
- if (value === undefined) {
19
- return [];
20
- }
21
- if (!Array.isArray(value)) {
22
- throw new Error("memory.entries must be an array when present");
23
- }
24
- return await Promise.all(value.map((entry, index) => readMemoryEntry(entry, index, workspaceDirPath)));
25
- }
26
- async function readMemoryEntry(value, index, workspaceDirPath) {
27
- const field = `memory.entries[${index}]`;
28
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
29
- throw new Error(`${field} must be a table`);
30
- }
31
- const entry = value;
32
- const displayPath = readRequiredString(entry.path, `${field}.path`);
33
- const description = readRequiredString(entry.description, `${field}.description`);
34
- const resolvedPath = resolve(workspaceDirPath, displayPath);
35
- const metadata = await statPath(resolvedPath, `${field}.path`);
36
- if (metadata.isFile()) {
37
- ensureDirectoryOnlyFieldIsAbsent(entry.inject_markdown_contents, `${field}.inject_markdown_contents`);
38
- ensureDirectoryOnlyFieldIsAbsent(entry.globs, `${field}.globs`);
39
- return {
40
- kind: "file",
41
- path: resolvedPath,
42
- displayPath,
43
- description,
44
- injectContent: readBoolean(entry.inject_content, `${field}.inject_content`, false),
45
- };
46
- }
47
- if (metadata.isDirectory()) {
48
- ensureFileOnlyFieldIsAbsent(entry.inject_content, `${field}.inject_content`);
49
- return {
50
- kind: "directory",
51
- path: resolvedPath,
52
- displayPath,
53
- description,
54
- injectMarkdownContents: readBoolean(entry.inject_markdown_contents, `${field}.inject_markdown_contents`, false),
55
- globs: readGlobList(entry.globs, `${field}.globs`),
56
- };
57
- }
58
- throw new Error(`${field}.path must point to a regular file or directory`);
59
- }
60
- async function statPath(path, field) {
61
- try {
62
- return await stat(path);
63
- }
64
- catch (error) {
65
- throw new Error(`${field} does not exist: ${path}`, { cause: error });
66
- }
67
- }
68
- function ensureDirectoryOnlyFieldIsAbsent(value, field) {
69
- if (value !== undefined) {
70
- throw new Error(`${field} is only valid for directory entries`);
71
- }
72
- }
73
- function ensureFileOnlyFieldIsAbsent(value, field) {
74
- if (value !== undefined) {
75
- throw new Error(`${field} is only valid for file entries`);
76
- }
77
- }
78
- function readBoolean(value, field, fallback) {
79
- if (value === undefined) {
80
- return fallback;
81
- }
82
- if (typeof value !== "boolean") {
83
- throw new Error(`${field} must be a boolean when present`);
84
- }
85
- return value;
86
- }
87
- function readGlobList(value, field) {
88
- if (value === undefined) {
89
- return [];
90
- }
91
- if (!Array.isArray(value)) {
92
- throw new Error(`${field} must be an array when present`);
93
- }
94
- return value.map((entry, index) => readRequiredString(entry, `${field}[${index}]`));
95
- }
96
- function readRequiredString(value, field) {
97
- if (typeof value !== "string") {
98
- throw new Error(`${field} must be a string`);
99
- }
100
- const trimmed = value.trim();
101
- if (trimmed.length === 0) {
102
- throw new Error(`${field} must not be empty`);
103
- }
104
- return trimmed;
105
- }
@@ -1,39 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { dirname, join, resolve } from "node:path";
3
- export const GATEWAY_CONFIG_FILE = "opencode-gateway.toml";
4
- export const OPENCODE_CONFIG_FILE = "opencode.json";
5
- export const OPENCODE_CONFIG_FILE_JSONC = "opencode.jsonc";
6
- export const OPENCODE_CONFIG_FILE_CANDIDATES = [OPENCODE_CONFIG_FILE_JSONC, OPENCODE_CONFIG_FILE];
7
- export const GATEWAY_WORKSPACE_DIR = "opencode-gateway-workspace";
8
- export function resolveGatewayConfigPath(env) {
9
- const explicit = env.OPENCODE_GATEWAY_CONFIG;
10
- if (explicit && explicit.trim().length > 0) {
11
- return resolve(explicit);
12
- }
13
- return join(resolveOpencodeConfigDir(env), GATEWAY_CONFIG_FILE);
14
- }
15
- export function resolveOpencodeConfigDir(env) {
16
- const explicit = env.OPENCODE_CONFIG_DIR;
17
- if (explicit && explicit.trim().length > 0) {
18
- return resolve(explicit);
19
- }
20
- return defaultOpencodeConfigDir(env);
21
- }
22
- export function resolveManagedOpencodeConfigDir(env) {
23
- return join(resolveConfigHome(env), "opencode-gateway", "opencode");
24
- }
25
- export function resolveGatewayWorkspacePath(configPath) {
26
- return join(dirname(configPath), GATEWAY_WORKSPACE_DIR);
27
- }
28
- export function defaultGatewayStateDbPath(env) {
29
- return join(resolveDataHome(env), "opencode-gateway", "state.db");
30
- }
31
- export function resolveConfigHome(env) {
32
- return env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
33
- }
34
- export function resolveDataHome(env) {
35
- return env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
36
- }
37
- function defaultOpencodeConfigDir(env) {
38
- return join(resolveConfigHome(env), "opencode");
39
- }
@@ -1,91 +0,0 @@
1
- export function parseTelegramConfig(value, env) {
2
- const table = readTelegramTable(value);
3
- const enabled = readBoolean(table.enabled, "channels.telegram.enabled", false);
4
- if (!enabled) {
5
- return { enabled: false };
6
- }
7
- const botTokenEnv = readString(table.bot_token_env, "channels.telegram.bot_token_env", "TELEGRAM_BOT_TOKEN");
8
- const pollTimeoutSeconds = readPollTimeoutSeconds(table.poll_timeout_seconds);
9
- const allowedChats = readIdentifierList(table.allowed_chats, "channels.telegram.allowed_chats");
10
- const allowedUsers = readIdentifierList(table.allowed_users, "channels.telegram.allowed_users");
11
- const botToken = env[botTokenEnv]?.trim();
12
- if (!botToken) {
13
- throw new Error(`Telegram is enabled but ${botTokenEnv} is not set`);
14
- }
15
- if (allowedChats.length === 0 && allowedUsers.length === 0) {
16
- throw new Error("Telegram is enabled but no allowlist entries were configured");
17
- }
18
- return {
19
- enabled: true,
20
- botToken,
21
- botTokenEnv,
22
- pollTimeoutSeconds,
23
- allowedChats,
24
- allowedUsers,
25
- };
26
- }
27
- function readTelegramTable(value) {
28
- if (value === undefined) {
29
- return {};
30
- }
31
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
32
- throw new Error("channels.telegram must be a table when present");
33
- }
34
- return value;
35
- }
36
- function readBoolean(value, field, fallback) {
37
- if (value === undefined) {
38
- return fallback;
39
- }
40
- if (typeof value !== "boolean") {
41
- throw new Error(`${field} must be a boolean when present`);
42
- }
43
- return value;
44
- }
45
- function readString(value, field, fallback) {
46
- if (value === undefined) {
47
- return fallback;
48
- }
49
- if (typeof value !== "string") {
50
- throw new Error(`${field} must be a string when present`);
51
- }
52
- const trimmed = value.trim();
53
- if (trimmed.length === 0) {
54
- throw new Error(`${field} must not be empty`);
55
- }
56
- return trimmed;
57
- }
58
- function readPollTimeoutSeconds(value) {
59
- if (value === undefined) {
60
- return 25;
61
- }
62
- if (typeof value !== "number" || !Number.isInteger(value)) {
63
- throw new Error("channels.telegram.poll_timeout_seconds must be an integer when present");
64
- }
65
- if (value < 1 || value > 50) {
66
- throw new Error("channels.telegram.poll_timeout_seconds must be between 1 and 50");
67
- }
68
- return value;
69
- }
70
- function readIdentifierList(value, field) {
71
- if (value === undefined) {
72
- return [];
73
- }
74
- if (!Array.isArray(value)) {
75
- throw new Error(`${field} must be an array when present`);
76
- }
77
- return value.map((entry) => normalizeIdentifier(entry, field));
78
- }
79
- function normalizeIdentifier(value, field) {
80
- if (typeof value === "string") {
81
- const trimmed = value.trim();
82
- if (trimmed.length === 0) {
83
- throw new Error(`${field} entries must not be empty`);
84
- }
85
- return trimmed;
86
- }
87
- if (typeof value === "number" && Number.isSafeInteger(value)) {
88
- return String(value);
89
- }
90
- throw new Error(`${field} entries must be strings or safe integers`);
91
- }
@@ -1,402 +0,0 @@
1
- import { formatError } from "../utils/error";
2
- const CRON_EFFECTIVE_TIME_ZONE_KEY = "cron.effective_timezone";
3
- const LEGACY_CRON_TIME_ZONE = "UTC";
4
- const MAX_STATUS_RUNS = 20;
5
- export class GatewayCronRuntime {
6
- executor;
7
- contract;
8
- store;
9
- logger;
10
- config;
11
- effectiveTimeZone;
12
- resolveConversationKeyForTarget;
13
- runningJobIds = new Set();
14
- running = false;
15
- constructor(executor, contract, store, logger, config, effectiveTimeZone, resolveConversationKeyForTarget) {
16
- this.executor = executor;
17
- this.contract = contract;
18
- this.store = store;
19
- this.logger = logger;
20
- this.config = config;
21
- this.effectiveTimeZone = effectiveTimeZone;
22
- this.resolveConversationKeyForTarget = resolveConversationKeyForTarget;
23
- }
24
- isEnabled() {
25
- return this.config.enabled;
26
- }
27
- isRunning() {
28
- return this.running;
29
- }
30
- runningJobs() {
31
- return this.runningJobIds.size;
32
- }
33
- timeZone() {
34
- return this.effectiveTimeZone;
35
- }
36
- start() {
37
- if (!this.config.enabled || this.running) {
38
- return;
39
- }
40
- this.running = true;
41
- void this.runLoop().finally(() => {
42
- this.running = false;
43
- });
44
- }
45
- listJobs(includeTerminal = false) {
46
- const jobs = this.store.listCronJobs();
47
- if (includeTerminal) {
48
- return jobs;
49
- }
50
- return jobs.filter((job) => !isTerminalJob(job));
51
- }
52
- getJobStatus(id, limit = 5) {
53
- const job = this.requireJob(normalizeId(id));
54
- const runs = this.store.listCronRuns(job.id, clampStatusLimit(limit));
55
- return {
56
- job,
57
- state: deriveJobState(job, runs[0] ?? null),
58
- runs,
59
- };
60
- }
61
- upsertJob(input) {
62
- const normalized = normalizeUpsertInput(input);
63
- const recordedAtMs = Date.now();
64
- const nextRunAtMs = computeNextRunAt(this.contract, normalized, recordedAtMs, this.effectiveTimeZone);
65
- this.store.upsertCronJob({
66
- ...normalized,
67
- nextRunAtMs,
68
- recordedAtMs,
69
- });
70
- return this.requireJob(normalized.id);
71
- }
72
- scheduleOnce(input) {
73
- const normalized = normalizeOnceInput(input);
74
- const recordedAtMs = Date.now();
75
- this.store.upsertCronJob({
76
- ...normalized,
77
- nextRunAtMs: normalized.runAtMs ?? recordedAtMs,
78
- recordedAtMs,
79
- });
80
- return this.requireJob(normalized.id);
81
- }
82
- cancelJob(id) {
83
- const job = this.store.getCronJob(normalizeId(id));
84
- if (job === null || !job.enabled) {
85
- return false;
86
- }
87
- this.store.setCronJobEnabled(job.id, false, Date.now());
88
- return true;
89
- }
90
- async runNow(id) {
91
- const job = this.requireJob(normalizeId(id));
92
- if (!job.enabled) {
93
- throw new Error(`schedule job is not active: ${job.id}`);
94
- }
95
- if (this.runningJobIds.has(job.id)) {
96
- throw new Error(`schedule job is already running: ${job.id}`);
97
- }
98
- this.runningJobIds.add(job.id);
99
- try {
100
- return await this.executeJob(job, Date.now(), null);
101
- }
102
- finally {
103
- this.runningJobIds.delete(job.id);
104
- }
105
- }
106
- async runLoop() {
107
- await this.reconcileOnce();
108
- for (;;) {
109
- await this.tickOnce();
110
- await sleep(this.config.tickSeconds * 1_000);
111
- }
112
- }
113
- async reconcileOnce(nowMs = Date.now()) {
114
- const abandoned = this.store.abandonRunningCronRuns(nowMs);
115
- if (abandoned > 0) {
116
- this.logger.log("warn", `abandoned ${abandoned} stale cron runs on startup`);
117
- }
118
- const storedTimeZone = this.readStoredEffectiveTimeZone();
119
- const previousTimeZone = storedTimeZone ?? LEGACY_CRON_TIME_ZONE;
120
- if (previousTimeZone !== this.effectiveTimeZone) {
121
- const message = storedTimeZone === null
122
- ? `rebasing enabled cron jobs from legacy ${LEGACY_CRON_TIME_ZONE} semantics to ${this.effectiveTimeZone}`
123
- : `cron time zone changed from ${previousTimeZone} to ${this.effectiveTimeZone}; rebasing enabled jobs`;
124
- this.logger.log("warn", message);
125
- this.rebaseJobs(this.store.listCronJobs().filter((job) => job.enabled && job.kind === "cron"), nowMs);
126
- }
127
- else {
128
- this.rebaseJobs(this.store.listOverdueCronJobs(nowMs), nowMs);
129
- }
130
- this.store.putStateValue(CRON_EFFECTIVE_TIME_ZONE_KEY, this.effectiveTimeZone, nowMs);
131
- }
132
- async tickOnce(nowMs = Date.now()) {
133
- const capacity = this.config.maxConcurrentRuns - this.runningJobIds.size;
134
- if (capacity <= 0) {
135
- return;
136
- }
137
- const dueJobs = this.store.listDueCronJobs(nowMs, capacity);
138
- for (const job of dueJobs) {
139
- if (this.runningJobIds.has(job.id)) {
140
- continue;
141
- }
142
- this.runningJobIds.add(job.id);
143
- void this.executeJob(job, job.nextRunAtMs, nowMs)
144
- .catch((error) => {
145
- this.logger.log("error", `schedule job ${job.id} failed: ${formatError(error)}`);
146
- })
147
- .finally(() => {
148
- this.runningJobIds.delete(job.id);
149
- });
150
- if (this.runningJobIds.size >= this.config.maxConcurrentRuns) {
151
- return;
152
- }
153
- }
154
- }
155
- async executeJob(job, scheduledForMs, nextRunBaseMs) {
156
- const startedAtMs = Date.now();
157
- if (job.kind === "cron" && nextRunBaseMs !== null) {
158
- const nextRunAtMs = computeNextRunAt(this.contract, job, Math.max(nextRunBaseMs, scheduledForMs), this.effectiveTimeZone);
159
- this.store.updateCronJobNextRun(job.id, nextRunAtMs, startedAtMs);
160
- }
161
- else if (job.kind === "once") {
162
- this.store.setCronJobEnabled(job.id, false, startedAtMs);
163
- }
164
- const runId = this.store.insertCronRun(job.id, scheduledForMs, startedAtMs);
165
- try {
166
- const report = await this.executor.dispatchScheduledJob({
167
- jobId: job.id,
168
- jobKind: job.kind,
169
- conversationKey: conversationKeyForJob(job),
170
- prompt: job.prompt,
171
- replyTarget: toReplyTarget(job),
172
- });
173
- this.store.finishCronRun(runId, "succeeded", Date.now(), report.responseText, null);
174
- await this.appendScheduleResultToTarget(job, scheduledForMs, {
175
- kind: "success",
176
- responseText: report.responseText,
177
- });
178
- return report;
179
- }
180
- catch (error) {
181
- const message = formatError(error);
182
- this.store.finishCronRun(runId, "failed", Date.now(), null, message);
183
- await this.appendScheduleResultToTarget(job, scheduledForMs, {
184
- kind: "failure",
185
- errorMessage: message,
186
- });
187
- throw error;
188
- }
189
- }
190
- async appendScheduleResultToTarget(job, scheduledForMs, outcome) {
191
- const replyTarget = toReplyTarget(job);
192
- if (replyTarget === null) {
193
- return;
194
- }
195
- try {
196
- await this.executor.appendContextToConversation({
197
- conversationKey: this.resolveConversationKeyForTarget(replyTarget),
198
- replyTarget,
199
- body: formatScheduleContextNote(job, scheduledForMs, outcome),
200
- recordedAtMs: Date.now(),
201
- });
202
- }
203
- catch (error) {
204
- this.logger.log("warn", `failed to append schedule result to ${replyTarget.channel}:${replyTarget.target}: ${formatError(error)}`);
205
- }
206
- }
207
- requireJob(id) {
208
- const job = this.store.getCronJob(id);
209
- if (job === null) {
210
- throw new Error(`unknown schedule job: ${id}`);
211
- }
212
- return job;
213
- }
214
- rebaseJobs(jobs, nowMs) {
215
- for (const job of jobs) {
216
- try {
217
- const nextRunAtMs = computeNextRunAt(this.contract, job, nowMs, this.effectiveTimeZone);
218
- this.store.updateCronJobNextRun(job.id, nextRunAtMs, nowMs);
219
- }
220
- catch (error) {
221
- this.logger.log("error", `failed to rebase cron job ${job.id}: ${formatError(error)}`);
222
- }
223
- }
224
- }
225
- readStoredEffectiveTimeZone() {
226
- const stored = this.store.getStateValue(CRON_EFFECTIVE_TIME_ZONE_KEY);
227
- if (stored === null) {
228
- return null;
229
- }
230
- try {
231
- return this.contract.normalizeCronTimeZone(stored);
232
- }
233
- catch (error) {
234
- this.logger.log("warn", `stored cron time zone is invalid (${stored}); treating as legacy ${LEGACY_CRON_TIME_ZONE}: ${formatError(error)}`);
235
- return null;
236
- }
237
- }
238
- }
239
- function normalizeUpsertInput(input) {
240
- const id = normalizeId(input.id);
241
- const schedule = normalizeRequiredField(input.schedule, "cron schedule");
242
- const prompt = normalizeRequiredField(input.prompt, "cron prompt");
243
- const deliveryChannel = normalizeOptionalField(input.deliveryChannel);
244
- const deliveryTarget = normalizeOptionalField(input.deliveryTarget);
245
- const deliveryTopic = normalizeOptionalField(input.deliveryTopic);
246
- if ((deliveryChannel === null) !== (deliveryTarget === null)) {
247
- throw new Error("cron delivery_channel and delivery_target must be provided together");
248
- }
249
- if (deliveryChannel === null && deliveryTopic !== null) {
250
- throw new Error("cron delivery_topic requires delivery_channel and delivery_target");
251
- }
252
- if (deliveryChannel !== null && deliveryChannel !== "telegram") {
253
- throw new Error(`unsupported cron delivery channel: ${deliveryChannel}`);
254
- }
255
- return {
256
- id,
257
- kind: "cron",
258
- schedule,
259
- runAtMs: null,
260
- prompt,
261
- enabled: input.enabled,
262
- deliveryChannel,
263
- deliveryTarget,
264
- deliveryTopic,
265
- nextRunAtMs: 0,
266
- recordedAtMs: 0,
267
- };
268
- }
269
- function normalizeOnceInput(input) {
270
- const id = normalizeId(input.id);
271
- const prompt = normalizeRequiredField(input.prompt, "schedule prompt");
272
- const deliveryChannel = normalizeOptionalField(input.deliveryChannel);
273
- const deliveryTarget = normalizeOptionalField(input.deliveryTarget);
274
- const deliveryTopic = normalizeOptionalField(input.deliveryTopic);
275
- if ((deliveryChannel === null) !== (deliveryTarget === null)) {
276
- throw new Error("schedule delivery_channel and delivery_target must be provided together");
277
- }
278
- if (deliveryChannel === null && deliveryTopic !== null) {
279
- throw new Error("schedule delivery_topic requires delivery_channel and delivery_target");
280
- }
281
- if (deliveryChannel !== null && deliveryChannel !== "telegram") {
282
- throw new Error(`unsupported schedule delivery channel: ${deliveryChannel}`);
283
- }
284
- const runAtMs = resolveOnceRunAt(input);
285
- return {
286
- id,
287
- kind: "once",
288
- schedule: null,
289
- runAtMs,
290
- prompt,
291
- enabled: true,
292
- deliveryChannel,
293
- deliveryTarget,
294
- deliveryTopic,
295
- nextRunAtMs: runAtMs,
296
- recordedAtMs: 0,
297
- };
298
- }
299
- function resolveOnceRunAt(input) {
300
- if (input.delaySeconds === null && input.runAtMs === null) {
301
- throw new Error("schedule_once requires delay_seconds or run_at_ms");
302
- }
303
- if (input.delaySeconds !== null && input.runAtMs !== null) {
304
- throw new Error("schedule_once accepts only one of delay_seconds or run_at_ms");
305
- }
306
- if (input.runAtMs !== null) {
307
- if (!Number.isSafeInteger(input.runAtMs) || input.runAtMs < 0) {
308
- throw new Error("schedule run_at_ms must be a non-negative integer");
309
- }
310
- return input.runAtMs;
311
- }
312
- const delaySeconds = input.delaySeconds ?? 0;
313
- if (!Number.isSafeInteger(delaySeconds) || delaySeconds < 0) {
314
- throw new Error("schedule delay_seconds must be a non-negative integer");
315
- }
316
- return Date.now() + delaySeconds * 1_000;
317
- }
318
- function normalizeId(id) {
319
- return normalizeRequiredField(id, "schedule id");
320
- }
321
- function normalizeRequiredField(value, field) {
322
- const trimmed = value.trim();
323
- if (trimmed.length === 0) {
324
- throw new Error(`${field} must not be empty`);
325
- }
326
- return trimmed;
327
- }
328
- function normalizeOptionalField(value) {
329
- if (value === null) {
330
- return null;
331
- }
332
- const trimmed = value.trim();
333
- return trimmed.length === 0 ? null : trimmed;
334
- }
335
- function toBindingCronJobSpec(job) {
336
- return {
337
- id: job.id,
338
- schedule: normalizeRequiredField(job.schedule ?? "", "cron schedule"),
339
- prompt: job.prompt,
340
- deliveryChannel: job.deliveryChannel,
341
- deliveryTarget: job.deliveryTarget,
342
- deliveryTopic: job.deliveryTopic,
343
- };
344
- }
345
- function computeNextRunAt(contract, job, afterMs, timeZone) {
346
- const nextRunAt = contract.nextCronRunAt(toBindingCronJobSpec(job), afterMs, timeZone);
347
- if (!Number.isSafeInteger(nextRunAt) || nextRunAt < 0) {
348
- throw new Error(`next cron run at is out of range for JavaScript: ${nextRunAt}`);
349
- }
350
- return nextRunAt;
351
- }
352
- function sleep(durationMs) {
353
- return new Promise((resolve) => {
354
- setTimeout(resolve, durationMs);
355
- });
356
- }
357
- function clampStatusLimit(limit) {
358
- if (!Number.isSafeInteger(limit) || limit <= 0) {
359
- throw new Error("schedule_status limit must be a positive integer");
360
- }
361
- return Math.min(limit, MAX_STATUS_RUNS);
362
- }
363
- function deriveJobState(job, latestRun) {
364
- if (latestRun?.status === "running") {
365
- return "running";
366
- }
367
- if (job.enabled) {
368
- return "scheduled";
369
- }
370
- if (latestRun !== null) {
371
- return latestRun.status;
372
- }
373
- return "canceled";
374
- }
375
- function isTerminalJob(job) {
376
- return !job.enabled;
377
- }
378
- function toReplyTarget(job) {
379
- if (job.deliveryChannel === null || job.deliveryTarget === null) {
380
- return null;
381
- }
382
- return {
383
- channel: job.deliveryChannel,
384
- target: job.deliveryTarget,
385
- topic: job.deliveryTopic,
386
- };
387
- }
388
- function conversationKeyForJob(job) {
389
- return job.kind === "cron" ? `cron:${job.id}` : `once:${job.id}`;
390
- }
391
- function formatScheduleContextNote(job, scheduledForMs, outcome) {
392
- const header = [
393
- "[Gateway schedule result]",
394
- `job_id=${job.id}`,
395
- `job_kind=${job.kind}`,
396
- `scheduled_for_ms=${scheduledForMs}`,
397
- ];
398
- if (outcome.kind === "success") {
399
- return [...header, "status=succeeded", "", outcome.responseText].join("\n");
400
- }
401
- return [...header, "status=failed", "", outcome.errorMessage].join("\n");
402
- }