opencode-gateway 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli/doctor.js +3 -1
  2. package/dist/cli/init.js +4 -1
  3. package/dist/cli/paths.js +1 -1
  4. package/dist/cli.js +13 -4
  5. package/dist/config/gateway.d.ts +1 -0
  6. package/dist/config/gateway.js +2 -1
  7. package/dist/config/paths.d.ts +2 -0
  8. package/dist/config/paths.js +5 -1
  9. package/dist/cron/runtime.d.ts +24 -5
  10. package/dist/cron/runtime.js +178 -13
  11. package/dist/delivery/text.js +1 -1
  12. package/dist/gateway.js +41 -35
  13. package/dist/index.js +9 -5
  14. package/dist/opencode/adapter.d.ts +2 -0
  15. package/dist/opencode/adapter.js +56 -7
  16. package/dist/runtime/conversation-coordinator.d.ts +4 -0
  17. package/dist/runtime/conversation-coordinator.js +22 -0
  18. package/dist/runtime/executor.d.ts +33 -5
  19. package/dist/runtime/executor.js +229 -22
  20. package/dist/runtime/runtime-singleton.d.ts +2 -0
  21. package/dist/runtime/runtime-singleton.js +28 -0
  22. package/dist/session/context.js +6 -0
  23. package/dist/store/migrations.js +15 -1
  24. package/dist/store/sqlite.d.ts +19 -2
  25. package/dist/store/sqlite.js +81 -4
  26. package/dist/tools/channel-target.d.ts +5 -0
  27. package/dist/tools/channel-target.js +6 -0
  28. package/dist/tools/cron-run.js +1 -1
  29. package/dist/tools/cron-upsert.d.ts +2 -1
  30. package/dist/tools/cron-upsert.js +20 -6
  31. package/dist/tools/{cron-remove.d.ts → schedule-cancel.d.ts} +1 -1
  32. package/dist/tools/schedule-cancel.js +12 -0
  33. package/dist/tools/schedule-format.d.ts +4 -0
  34. package/dist/tools/schedule-format.js +48 -0
  35. package/dist/tools/{cron-list.d.ts → schedule-list.d.ts} +1 -1
  36. package/dist/tools/schedule-list.js +17 -0
  37. package/dist/tools/schedule-once.d.ts +4 -0
  38. package/dist/tools/schedule-once.js +43 -0
  39. package/dist/tools/schedule-status.d.ts +3 -0
  40. package/dist/tools/schedule-status.js +23 -0
  41. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
  42. package/package.json +4 -4
  43. package/dist/tools/cron-list.js +0 -34
  44. package/dist/tools/cron-remove.js +0 -12
@@ -1,18 +1,20 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { GATEWAY_CONFIG_FILE, OPENCODE_CONFIG_FILE } from "../config/paths";
3
+ import { GATEWAY_CONFIG_FILE, OPENCODE_CONFIG_FILE, resolveGatewayWorkspacePath } from "../config/paths";
4
4
  import { parseOpencodeConfig } from "./opencode-config";
5
5
  import { pathExists, resolveCliConfigDir } from "./paths";
6
6
  export async function runDoctor(options, env) {
7
7
  const configDir = resolveCliConfigDir(options, env);
8
8
  const opencodeConfigPath = join(configDir, OPENCODE_CONFIG_FILE);
9
9
  const gatewayConfigPath = join(configDir, GATEWAY_CONFIG_FILE);
10
+ const workspaceDirPath = resolveGatewayWorkspacePath(gatewayConfigPath);
10
11
  const opencodeStatus = await inspectOpencodeConfig(opencodeConfigPath);
11
12
  const gatewayOverride = env.OPENCODE_GATEWAY_CONFIG?.trim() || null;
12
13
  console.log("doctor report");
13
14
  console.log(` config dir: ${configDir}`);
14
15
  console.log(` opencode config: ${await describePath(opencodeConfigPath)}`);
15
16
  console.log(` gateway config: ${await describePath(gatewayConfigPath)}`);
17
+ console.log(` gateway workspace: ${await describePath(workspaceDirPath)}`);
16
18
  console.log(` gateway config override: ${gatewayOverride ?? "not set"}`);
17
19
  console.log(` plugin configured: ${opencodeStatus.pluginConfigured}`);
18
20
  console.log(` TELEGRAM_BOT_TOKEN: ${env.TELEGRAM_BOT_TOKEN?.trim() ? "set" : "missing"}`);
package/dist/cli/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
- import { defaultGatewayStateDbPath, GATEWAY_CONFIG_FILE, OPENCODE_CONFIG_FILE, } from "../config/paths";
3
+ import { defaultGatewayStateDbPath, GATEWAY_CONFIG_FILE, OPENCODE_CONFIG_FILE, resolveGatewayWorkspacePath, } from "../config/paths";
4
4
  import { createDefaultOpencodeConfig, ensureGatewayPlugin, parseOpencodeConfig, stringifyOpencodeConfig, } from "./opencode-config";
5
5
  import { pathExists, resolveCliConfigDir } from "./paths";
6
6
  import { buildGatewayConfigTemplate } from "./templates";
@@ -8,7 +8,9 @@ export async function runInit(options, env) {
8
8
  const configDir = resolveCliConfigDir(options, env);
9
9
  const opencodeConfigPath = join(configDir, OPENCODE_CONFIG_FILE);
10
10
  const gatewayConfigPath = join(configDir, GATEWAY_CONFIG_FILE);
11
+ const workspaceDirPath = resolveGatewayWorkspacePath(gatewayConfigPath);
11
12
  await mkdir(configDir, { recursive: true });
13
+ await mkdir(workspaceDirPath, { recursive: true });
12
14
  let opencodeStatus = "already present";
13
15
  if (!(await pathExists(opencodeConfigPath))) {
14
16
  await writeFile(opencodeConfigPath, stringifyOpencodeConfig(createDefaultOpencodeConfig(options.managed)));
@@ -32,4 +34,5 @@ export async function runInit(options, env) {
32
34
  console.log(`config dir: ${configDir}`);
33
35
  console.log(`opencode config: ${opencodeConfigPath} (${opencodeStatus})`);
34
36
  console.log(`gateway config: ${gatewayConfigPath} (${gatewayStatus})`);
37
+ console.log(`gateway workspace: ${workspaceDirPath} (ready)`);
35
38
  }
package/dist/cli/paths.js CHANGED
@@ -1,5 +1,5 @@
1
- import { access } from "node:fs/promises";
2
1
  import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { resolveManagedOpencodeConfigDir, resolveOpencodeConfigDir } from "../config/paths";
5
5
  export function resolveCliConfigDir(options, env) {
package/dist/cli.js CHANGED
@@ -62,9 +62,10 @@ import { join as join2 } from "node:path";
62
62
 
63
63
  // src/config/paths.ts
64
64
  import { homedir } from "node:os";
65
- import { join, resolve as resolve2 } from "node:path";
65
+ import { dirname, join, resolve as resolve2 } from "node:path";
66
66
  var GATEWAY_CONFIG_FILE = "opencode-gateway.toml";
67
67
  var OPENCODE_CONFIG_FILE = "opencode.json";
68
+ var GATEWAY_WORKSPACE_DIR = "opencode-gateway-workspace";
68
69
  function resolveOpencodeConfigDir(env) {
69
70
  const explicit = env.OPENCODE_CONFIG_DIR;
70
71
  if (explicit && explicit.trim().length > 0) {
@@ -75,6 +76,9 @@ function resolveOpencodeConfigDir(env) {
75
76
  function resolveManagedOpencodeConfigDir(env) {
76
77
  return join(resolveConfigHome(env), "opencode-gateway", "opencode");
77
78
  }
79
+ function resolveGatewayWorkspacePath(configPath) {
80
+ return join(dirname(configPath), GATEWAY_WORKSPACE_DIR);
81
+ }
78
82
  function defaultGatewayStateDbPath(env) {
79
83
  return join(resolveDataHome(env), "opencode-gateway", "state.db");
80
84
  }
@@ -153,8 +157,8 @@ function formatError(error) {
153
157
  }
154
158
 
155
159
  // src/cli/paths.ts
156
- import { access } from "node:fs/promises";
157
160
  import { constants } from "node:fs";
161
+ import { access } from "node:fs/promises";
158
162
  import { resolve as resolve3 } from "node:path";
159
163
  function resolveCliConfigDir(options, env) {
160
164
  if (options.configDir !== null) {
@@ -179,12 +183,14 @@ async function runDoctor(options, env) {
179
183
  const configDir = resolveCliConfigDir(options, env);
180
184
  const opencodeConfigPath = join2(configDir, OPENCODE_CONFIG_FILE);
181
185
  const gatewayConfigPath = join2(configDir, GATEWAY_CONFIG_FILE);
186
+ const workspaceDirPath = resolveGatewayWorkspacePath(gatewayConfigPath);
182
187
  const opencodeStatus = await inspectOpencodeConfig(opencodeConfigPath);
183
188
  const gatewayOverride = env.OPENCODE_GATEWAY_CONFIG?.trim() || null;
184
189
  console.log("doctor report");
185
190
  console.log(` config dir: ${configDir}`);
186
191
  console.log(` opencode config: ${await describePath(opencodeConfigPath)}`);
187
192
  console.log(` gateway config: ${await describePath(gatewayConfigPath)}`);
193
+ console.log(` gateway workspace: ${await describePath(workspaceDirPath)}`);
188
194
  console.log(` gateway config override: ${gatewayOverride ?? "not set"}`);
189
195
  console.log(` plugin configured: ${opencodeStatus.pluginConfigured}`);
190
196
  console.log(` TELEGRAM_BOT_TOKEN: ${env.TELEGRAM_BOT_TOKEN?.trim() ? "set" : "missing"}`);
@@ -231,7 +237,7 @@ async function inspectOpencodeConfig(path) {
231
237
 
232
238
  // src/cli/init.ts
233
239
  import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
234
- import { dirname, join as join3 } from "node:path";
240
+ import { dirname as dirname2, join as join3 } from "node:path";
235
241
 
236
242
  // src/cli/templates.ts
237
243
  function buildGatewayConfigTemplate(stateDbPath) {
@@ -267,7 +273,9 @@ async function runInit(options, env) {
267
273
  const configDir = resolveCliConfigDir(options, env);
268
274
  const opencodeConfigPath = join3(configDir, OPENCODE_CONFIG_FILE);
269
275
  const gatewayConfigPath = join3(configDir, GATEWAY_CONFIG_FILE);
276
+ const workspaceDirPath = resolveGatewayWorkspacePath(gatewayConfigPath);
270
277
  await mkdir(configDir, { recursive: true });
278
+ await mkdir(workspaceDirPath, { recursive: true });
271
279
  let opencodeStatus = "already present";
272
280
  if (!await pathExists(opencodeConfigPath)) {
273
281
  await writeFile(opencodeConfigPath, stringifyOpencodeConfig(createDefaultOpencodeConfig(options.managed)));
@@ -283,13 +291,14 @@ async function runInit(options, env) {
283
291
  }
284
292
  let gatewayStatus = "already present";
285
293
  if (!await pathExists(gatewayConfigPath)) {
286
- await mkdir(dirname(gatewayConfigPath), { recursive: true });
294
+ await mkdir(dirname2(gatewayConfigPath), { recursive: true });
287
295
  await writeFile(gatewayConfigPath, buildGatewayConfigTemplate(defaultGatewayStateDbPath(env)));
288
296
  gatewayStatus = "created";
289
297
  }
290
298
  console.log(`config dir: ${configDir}`);
291
299
  console.log(`opencode config: ${opencodeConfigPath} (${opencodeStatus})`);
292
300
  console.log(`gateway config: ${gatewayConfigPath} (${gatewayStatus})`);
301
+ console.log(`gateway workspace: ${workspaceDirPath} (ready)`);
293
302
  }
294
303
 
295
304
  // src/cli.ts
@@ -15,6 +15,7 @@ export type GatewayConfig = {
15
15
  configPath: string;
16
16
  stateDbPath: string;
17
17
  mediaRootPath: string;
18
+ workspaceDirPath: string;
18
19
  hasLegacyGatewayTimezone: boolean;
19
20
  legacyGatewayTimezone: string | null;
20
21
  mailbox: GatewayMailboxConfig;
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import { parseCronConfig } from "./cron";
4
- import { defaultGatewayStateDbPath, resolveGatewayConfigPath } from "./paths";
4
+ import { defaultGatewayStateDbPath, resolveGatewayConfigPath, resolveGatewayWorkspacePath } from "./paths";
5
5
  import { parseTelegramConfig } from "./telegram";
6
6
  export async function loadGatewayConfig(env = process.env) {
7
7
  const configPath = resolveGatewayConfigPath(env);
@@ -15,6 +15,7 @@ export async function loadGatewayConfig(env = process.env) {
15
15
  configPath,
16
16
  stateDbPath,
17
17
  mediaRootPath: resolveMediaRootPath(stateDbPath),
18
+ workspaceDirPath: resolveGatewayWorkspacePath(configPath),
18
19
  hasLegacyGatewayTimezone: rawConfig?.gateway?.timezone !== undefined,
19
20
  legacyGatewayTimezone: readLegacyGatewayTimezone(rawConfig?.gateway?.timezone),
20
21
  mailbox: parseMailboxConfig(rawConfig?.gateway?.mailbox),
@@ -1,9 +1,11 @@
1
1
  export declare const GATEWAY_CONFIG_FILE = "opencode-gateway.toml";
2
2
  export declare const OPENCODE_CONFIG_FILE = "opencode.json";
3
+ export declare const GATEWAY_WORKSPACE_DIR = "opencode-gateway-workspace";
3
4
  type EnvSource = Record<string, string | undefined>;
4
5
  export declare function resolveGatewayConfigPath(env: EnvSource): string;
5
6
  export declare function resolveOpencodeConfigDir(env: EnvSource): string;
6
7
  export declare function resolveManagedOpencodeConfigDir(env: EnvSource): string;
8
+ export declare function resolveGatewayWorkspacePath(configPath: string): string;
7
9
  export declare function defaultGatewayStateDbPath(env: EnvSource): string;
8
10
  export declare function resolveConfigHome(env: EnvSource): string;
9
11
  export declare function resolveDataHome(env: EnvSource): string;
@@ -1,7 +1,8 @@
1
1
  import { homedir } from "node:os";
2
- import { join, resolve } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
3
  export const GATEWAY_CONFIG_FILE = "opencode-gateway.toml";
4
4
  export const OPENCODE_CONFIG_FILE = "opencode.json";
5
+ export const GATEWAY_WORKSPACE_DIR = "opencode-gateway-workspace";
5
6
  export function resolveGatewayConfigPath(env) {
6
7
  const explicit = env.OPENCODE_GATEWAY_CONFIG;
7
8
  if (explicit && explicit.trim().length > 0) {
@@ -19,6 +20,9 @@ export function resolveOpencodeConfigDir(env) {
19
20
  export function resolveManagedOpencodeConfigDir(env) {
20
21
  return join(resolveConfigHome(env), "opencode-gateway", "opencode");
21
22
  }
23
+ export function resolveGatewayWorkspacePath(configPath) {
24
+ return join(dirname(configPath), GATEWAY_WORKSPACE_DIR);
25
+ }
22
26
  export function defaultGatewayStateDbPath(env) {
23
27
  return join(resolveDataHome(env), "opencode-gateway", "state.db");
24
28
  }
@@ -1,7 +1,7 @@
1
- import type { BindingLoggerHost, BindingRuntimeReport, GatewayContract } from "../binding";
1
+ import type { BindingDeliveryTarget, BindingLoggerHost, BindingRuntimeReport, GatewayContract } from "../binding";
2
2
  import type { CronConfig } from "../config/cron";
3
3
  import type { GatewayExecutorLike } from "../runtime/executor";
4
- import type { CronJobRecord, SqliteStore } from "../store/sqlite";
4
+ import type { CronJobRecord, CronRunRecord, SqliteStore } from "../store/sqlite";
5
5
  export type UpsertCronJobInput = {
6
6
  id: string;
7
7
  schedule: string;
@@ -11,6 +11,21 @@ export type UpsertCronJobInput = {
11
11
  deliveryTarget: string | null;
12
12
  deliveryTopic: string | null;
13
13
  };
14
+ export type ScheduleOnceInput = {
15
+ id: string;
16
+ prompt: string;
17
+ delaySeconds: number | null;
18
+ runAtMs: number | null;
19
+ deliveryChannel: string | null;
20
+ deliveryTarget: string | null;
21
+ deliveryTopic: string | null;
22
+ };
23
+ export type ScheduleJobState = "scheduled" | "running" | "succeeded" | "failed" | "abandoned" | "canceled";
24
+ export type ScheduleJobStatus = {
25
+ job: CronJobRecord;
26
+ state: ScheduleJobState;
27
+ runs: CronRunRecord[];
28
+ };
14
29
  export declare class GatewayCronRuntime {
15
30
  private readonly executor;
16
31
  private readonly contract;
@@ -18,22 +33,26 @@ export declare class GatewayCronRuntime {
18
33
  private readonly logger;
19
34
  private readonly config;
20
35
  private readonly effectiveTimeZone;
36
+ private readonly resolveConversationKeyForTarget;
21
37
  private readonly runningJobIds;
22
38
  private running;
23
- constructor(executor: GatewayExecutorLike, contract: GatewayContract, store: SqliteStore, logger: BindingLoggerHost, config: CronConfig, effectiveTimeZone: string);
39
+ constructor(executor: GatewayExecutorLike, contract: GatewayContract, store: SqliteStore, logger: BindingLoggerHost, config: CronConfig, effectiveTimeZone: string, resolveConversationKeyForTarget: (target: BindingDeliveryTarget) => string);
24
40
  isEnabled(): boolean;
25
41
  isRunning(): boolean;
26
42
  runningJobs(): number;
27
43
  timeZone(): string;
28
44
  start(): void;
29
- listJobs(): CronJobRecord[];
45
+ listJobs(includeTerminal?: boolean): CronJobRecord[];
46
+ getJobStatus(id: string, limit?: number): ScheduleJobStatus;
30
47
  upsertJob(input: UpsertCronJobInput): CronJobRecord;
31
- removeJob(id: string): boolean;
48
+ scheduleOnce(input: ScheduleOnceInput): CronJobRecord;
49
+ cancelJob(id: string): boolean;
32
50
  runNow(id: string): Promise<BindingRuntimeReport>;
33
51
  private runLoop;
34
52
  reconcileOnce(nowMs?: number): Promise<void>;
35
53
  tickOnce(nowMs?: number): Promise<void>;
36
54
  private executeJob;
55
+ private appendScheduleResultToTarget;
37
56
  private requireJob;
38
57
  private rebaseJobs;
39
58
  private readStoredEffectiveTimeZone;
@@ -1,6 +1,7 @@
1
1
  import { formatError } from "../utils/error";
2
2
  const CRON_EFFECTIVE_TIME_ZONE_KEY = "cron.effective_timezone";
3
3
  const LEGACY_CRON_TIME_ZONE = "UTC";
4
+ const MAX_STATUS_RUNS = 20;
4
5
  export class GatewayCronRuntime {
5
6
  executor;
6
7
  contract;
@@ -8,15 +9,17 @@ export class GatewayCronRuntime {
8
9
  logger;
9
10
  config;
10
11
  effectiveTimeZone;
12
+ resolveConversationKeyForTarget;
11
13
  runningJobIds = new Set();
12
14
  running = false;
13
- constructor(executor, contract, store, logger, config, effectiveTimeZone) {
15
+ constructor(executor, contract, store, logger, config, effectiveTimeZone, resolveConversationKeyForTarget) {
14
16
  this.executor = executor;
15
17
  this.contract = contract;
16
18
  this.store = store;
17
19
  this.logger = logger;
18
20
  this.config = config;
19
21
  this.effectiveTimeZone = effectiveTimeZone;
22
+ this.resolveConversationKeyForTarget = resolveConversationKeyForTarget;
20
23
  }
21
24
  isEnabled() {
22
25
  return this.config.enabled;
@@ -39,8 +42,21 @@ export class GatewayCronRuntime {
39
42
  this.running = false;
40
43
  });
41
44
  }
42
- listJobs() {
43
- return this.store.listCronJobs();
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
+ };
44
60
  }
45
61
  upsertJob(input) {
46
62
  const normalized = normalizeUpsertInput(input);
@@ -53,13 +69,31 @@ export class GatewayCronRuntime {
53
69
  });
54
70
  return this.requireJob(normalized.id);
55
71
  }
56
- removeJob(id) {
57
- return this.store.removeCronJob(normalizeId(id));
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;
58
89
  }
59
90
  async runNow(id) {
60
91
  const job = this.requireJob(normalizeId(id));
92
+ if (!job.enabled) {
93
+ throw new Error(`schedule job is not active: ${job.id}`);
94
+ }
61
95
  if (this.runningJobIds.has(job.id)) {
62
- throw new Error(`cron job is already running: ${job.id}`);
96
+ throw new Error(`schedule job is already running: ${job.id}`);
63
97
  }
64
98
  this.runningJobIds.add(job.id);
65
99
  try {
@@ -88,7 +122,7 @@ export class GatewayCronRuntime {
88
122
  ? `rebasing enabled cron jobs from legacy ${LEGACY_CRON_TIME_ZONE} semantics to ${this.effectiveTimeZone}`
89
123
  : `cron time zone changed from ${previousTimeZone} to ${this.effectiveTimeZone}; rebasing enabled jobs`;
90
124
  this.logger.log("warn", message);
91
- this.rebaseJobs(this.store.listCronJobs().filter((job) => job.enabled), nowMs);
125
+ this.rebaseJobs(this.store.listCronJobs().filter((job) => job.enabled && job.kind === "cron"), nowMs);
92
126
  }
93
127
  else {
94
128
  this.rebaseJobs(this.store.listOverdueCronJobs(nowMs), nowMs);
@@ -108,7 +142,7 @@ export class GatewayCronRuntime {
108
142
  this.runningJobIds.add(job.id);
109
143
  void this.executeJob(job, job.nextRunAtMs, nowMs)
110
144
  .catch((error) => {
111
- this.logger.log("error", `cron job ${job.id} failed: ${formatError(error)}`);
145
+ this.logger.log("error", `schedule job ${job.id} failed: ${formatError(error)}`);
112
146
  })
113
147
  .finally(() => {
114
148
  this.runningJobIds.delete(job.id);
@@ -120,26 +154,60 @@ export class GatewayCronRuntime {
120
154
  }
121
155
  async executeJob(job, scheduledForMs, nextRunBaseMs) {
122
156
  const startedAtMs = Date.now();
123
- if (nextRunBaseMs !== null) {
157
+ if (job.kind === "cron" && nextRunBaseMs !== null) {
124
158
  const nextRunAtMs = computeNextRunAt(this.contract, job, Math.max(nextRunBaseMs, scheduledForMs), this.effectiveTimeZone);
125
159
  this.store.updateCronJobNextRun(job.id, nextRunAtMs, startedAtMs);
126
160
  }
161
+ else if (job.kind === "once") {
162
+ this.store.setCronJobEnabled(job.id, false, startedAtMs);
163
+ }
127
164
  const runId = this.store.insertCronRun(job.id, scheduledForMs, startedAtMs);
128
165
  try {
129
- const report = await this.executor.dispatchCronJob(toBindingCronJobSpec(job));
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
+ });
130
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
+ });
131
178
  return report;
132
179
  }
133
180
  catch (error) {
134
181
  const message = formatError(error);
135
182
  this.store.finishCronRun(runId, "failed", Date.now(), null, message);
183
+ await this.appendScheduleResultToTarget(job, scheduledForMs, {
184
+ kind: "failure",
185
+ errorMessage: message,
186
+ });
136
187
  throw error;
137
188
  }
138
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
+ }
139
207
  requireJob(id) {
140
208
  const job = this.store.getCronJob(id);
141
209
  if (job === null) {
142
- throw new Error(`unknown cron job: ${id}`);
210
+ throw new Error(`unknown schedule job: ${id}`);
143
211
  }
144
212
  return job;
145
213
  }
@@ -186,7 +254,9 @@ function normalizeUpsertInput(input) {
186
254
  }
187
255
  return {
188
256
  id,
257
+ kind: "cron",
189
258
  schedule,
259
+ runAtMs: null,
190
260
  prompt,
191
261
  enabled: input.enabled,
192
262
  deliveryChannel,
@@ -196,8 +266,57 @@ function normalizeUpsertInput(input) {
196
266
  recordedAtMs: 0,
197
267
  };
198
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
+ }
199
318
  function normalizeId(id) {
200
- return normalizeRequiredField(id, "cron id");
319
+ return normalizeRequiredField(id, "schedule id");
201
320
  }
202
321
  function normalizeRequiredField(value, field) {
203
322
  const trimmed = value.trim();
@@ -216,7 +335,7 @@ function normalizeOptionalField(value) {
216
335
  function toBindingCronJobSpec(job) {
217
336
  return {
218
337
  id: job.id,
219
- schedule: job.schedule,
338
+ schedule: normalizeRequiredField(job.schedule ?? "", "cron schedule"),
220
339
  prompt: job.prompt,
221
340
  deliveryChannel: job.deliveryChannel,
222
341
  deliveryTarget: job.deliveryTarget,
@@ -235,3 +354,49 @@ function sleep(durationMs) {
235
354
  setTimeout(resolve, durationMs);
236
355
  });
237
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
+ }
@@ -114,7 +114,7 @@ class ProgressiveTextDeliverySession {
114
114
  this.telegramSupport.startTyping(this.target);
115
115
  }
116
116
  async preview(text) {
117
- if (this.previewFailed || this.closed) {
117
+ if (this.previewFailed || this.closed || text.trim().length === 0) {
118
118
  return;
119
119
  }
120
120
  const runPreview = async () => {
package/dist/gateway.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mkdir } from "node:fs/promises";
1
2
  import { loadGatewayConfig } from "./config/gateway";
2
3
  import { GatewayCronRuntime } from "./cron/runtime";
3
4
  import { TelegramProgressiveSupport } from "./delivery/telegram";
@@ -13,7 +14,9 @@ import { createQuestionClient } from "./questions/client";
13
14
  import { GatewayQuestionRuntime } from "./questions/runtime";
14
15
  import { GatewayExecutor } from "./runtime/executor";
15
16
  import { GatewayMailboxRuntime } from "./runtime/mailbox";
17
+ import { getOrCreateRuntimeSingleton } from "./runtime/runtime-singleton";
16
18
  import { GatewaySessionContext } from "./session/context";
19
+ import { resolveConversationKeyForTarget } from "./session/conversation-key";
17
20
  import { ChannelSessionSwitcher } from "./session/switcher";
18
21
  import { openSqliteStore } from "./store/sqlite";
19
22
  import { TelegramBotClient } from "./telegram/client";
@@ -56,41 +59,44 @@ export class GatewayPluginRuntime {
56
59
  }
57
60
  export async function createGatewayRuntime(module, input) {
58
61
  const config = await loadGatewayConfig();
59
- const logger = new ConsoleLoggerHost();
60
- if (config.hasLegacyGatewayTimezone) {
61
- const suffix = config.legacyGatewayTimezone === null ? "" : ` (${config.legacyGatewayTimezone})`;
62
- logger.log("warn", `gateway.timezone${suffix} is ignored; use cron.timezone instead`);
63
- }
64
- const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
65
- const store = await openSqliteStore(config.stateDbPath);
66
- const sessionContext = new GatewaySessionContext(store);
67
- const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
68
- const telegramMediaStore = config.telegram.enabled && telegramClient !== null
69
- ? new TelegramInboundMediaStore(telegramClient, config.mediaRootPath)
70
- : null;
71
- const mailboxRouter = new GatewayMailboxRouter(config.mailbox.routes);
72
- const opencodeEvents = new OpencodeEventHub();
73
- const opencode = new OpencodeSdkAdapter(input.client, input.directory);
74
- const questionClient = createQuestionClient(input.client, input.serverUrl, input.directory);
75
- const transport = new GatewayTransportHost(telegramClient, store);
76
- const files = new ChannelFileSender(telegramClient);
77
- const channelSessions = new ChannelSessionSwitcher(store, sessionContext, mailboxRouter, module, opencode, config.telegram.enabled);
78
- const questions = new GatewayQuestionRuntime(questionClient, input.directory, store, sessionContext, transport, telegramClient, logger);
79
- const progressiveSupport = new TelegramProgressiveSupport(telegramClient, store, logger);
80
- const delivery = new GatewayTextDelivery(transport, store, progressiveSupport);
81
- const executor = new GatewayExecutor(module, store, opencode, opencodeEvents, delivery, logger);
82
- const mailbox = new GatewayMailboxRuntime(executor, store, logger, config.mailbox, questions);
83
- const cron = new GatewayCronRuntime(executor, module, store, logger, config.cron, effectiveCronTimeZone);
84
- const eventStream = new OpencodeEventStream(input.client, input.directory, opencodeEvents, [questions], logger);
85
- const telegramPolling = config.telegram.enabled && telegramClient !== null && telegramMediaStore !== null
86
- ? new TelegramPollingService(telegramClient, mailbox, store, logger, config.telegram, mailboxRouter, telegramMediaStore, questions)
87
- : null;
88
- const telegram = new GatewayTelegramRuntime(telegramClient, delivery, store, logger, config.telegram, telegramPolling, eventStream);
89
- eventStream.start();
90
- cron.start();
91
- mailbox.start();
92
- telegram.start();
93
- return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext);
62
+ return await getOrCreateRuntimeSingleton(config.configPath, async () => {
63
+ await mkdir(config.workspaceDirPath, { recursive: true });
64
+ const logger = new ConsoleLoggerHost();
65
+ if (config.hasLegacyGatewayTimezone) {
66
+ const suffix = config.legacyGatewayTimezone === null ? "" : ` (${config.legacyGatewayTimezone})`;
67
+ logger.log("warn", `gateway.timezone${suffix} is ignored; use cron.timezone instead`);
68
+ }
69
+ const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
70
+ const store = await openSqliteStore(config.stateDbPath);
71
+ const sessionContext = new GatewaySessionContext(store);
72
+ const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
73
+ const telegramMediaStore = config.telegram.enabled && telegramClient !== null
74
+ ? new TelegramInboundMediaStore(telegramClient, config.mediaRootPath)
75
+ : null;
76
+ const mailboxRouter = new GatewayMailboxRouter(config.mailbox.routes);
77
+ const opencodeEvents = new OpencodeEventHub();
78
+ const opencode = new OpencodeSdkAdapter(input.client, config.workspaceDirPath);
79
+ const questionClient = createQuestionClient(input.client, input.serverUrl, config.workspaceDirPath);
80
+ const transport = new GatewayTransportHost(telegramClient, store);
81
+ const files = new ChannelFileSender(telegramClient);
82
+ const channelSessions = new ChannelSessionSwitcher(store, sessionContext, mailboxRouter, module, opencode, config.telegram.enabled);
83
+ const questions = new GatewayQuestionRuntime(questionClient, config.workspaceDirPath, store, sessionContext, transport, telegramClient, logger);
84
+ const progressiveSupport = new TelegramProgressiveSupport(telegramClient, store, logger);
85
+ const delivery = new GatewayTextDelivery(transport, store, progressiveSupport);
86
+ const executor = new GatewayExecutor(module, store, opencode, opencodeEvents, delivery, logger);
87
+ const mailbox = new GatewayMailboxRuntime(executor, store, logger, config.mailbox, questions);
88
+ const cron = new GatewayCronRuntime(executor, module, store, logger, config.cron, effectiveCronTimeZone, (target) => resolveConversationKeyForTarget(target, mailboxRouter, module));
89
+ const eventStream = new OpencodeEventStream(input.client, config.workspaceDirPath, opencodeEvents, [questions], logger);
90
+ const telegramPolling = config.telegram.enabled && telegramClient !== null && telegramMediaStore !== null
91
+ ? new TelegramPollingService(telegramClient, mailbox, store, logger, config.telegram, mailboxRouter, telegramMediaStore, questions)
92
+ : null;
93
+ const telegram = new GatewayTelegramRuntime(telegramClient, delivery, store, logger, config.telegram, telegramPolling, eventStream);
94
+ eventStream.start();
95
+ cron.start();
96
+ mailbox.start();
97
+ telegram.start();
98
+ return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext);
99
+ });
94
100
  }
95
101
  function resolveEffectiveCronTimeZone(module, config) {
96
102
  const candidate = config.cron.timezone ?? resolveRuntimeLocalTimeZone();