@roamcode.ai/server 1.3.0 → 1.4.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/dist/index.js CHANGED
@@ -9753,6 +9753,32 @@ var CloudHostHeartbeatV1Schema = z5.object({
9753
9753
  context.addIssue({ code: "custom", path: ["capabilities"], message: "capabilities must be unique" });
9754
9754
  }
9755
9755
  });
9756
+ var CloudAutomationWebhookRegistrationSchema = z5.object({
9757
+ hookId: z5.string().regex(/^rcwh_[A-Za-z0-9_-]{24,80}$/),
9758
+ automationId: SafeIdentifierSchema,
9759
+ triggerId: SafeIdentifierSchema,
9760
+ secretHash: z5.string().regex(/^[a-f0-9]{64}$/),
9761
+ enabled: z5.boolean()
9762
+ }).strict();
9763
+ var CloudAutomationInvocationSchema = z5.object({
9764
+ id: z5.uuid(),
9765
+ automationId: SafeIdentifierSchema,
9766
+ triggerId: SafeIdentifierSchema,
9767
+ hookId: z5.string().regex(/^rcwh_[A-Za-z0-9_-]{24,80}$/),
9768
+ createdAt: TimestampSchema
9769
+ }).strict();
9770
+ var CloudAutomationSyncRequestSchema = z5.object({
9771
+ v: z5.literal(1),
9772
+ organizationId: SafeIdentifierSchema,
9773
+ hostId: SafeIdentifierSchema,
9774
+ instanceId: SafeIdentifierSchema,
9775
+ registrations: z5.array(CloudAutomationWebhookRegistrationSchema).max(128).refine(
9776
+ (registrations) => new Set(registrations.map((registration) => registration.hookId)).size === registrations.length,
9777
+ "webhook identities must be unique"
9778
+ ),
9779
+ acknowledgements: z5.array(z5.uuid()).max(200).refine((values) => new Set(values).size === values.length)
9780
+ }).strict();
9781
+ var CloudAutomationSyncResponseSchema = z5.object({ invocations: z5.array(CloudAutomationInvocationSchema).max(50) }).strict();
9756
9782
  var CloudAuthorizationScopeV1Schema = z5.discriminatedUnion("type", [
9757
9783
  z5.object({ type: z5.literal("organization") }).strict(),
9758
9784
  z5.object({ type: z5.literal("host"), id: SafeIdentifierSchema }).strict(),
@@ -10481,6 +10507,7 @@ function resolveCloudHostConfig(env, dataDir) {
10481
10507
 
10482
10508
  // src/cloud-host-runtime.ts
10483
10509
  var CLOUD_HOST_HEARTBEAT_PATH = "/api/v1/hosts/heartbeat";
10510
+ var CLOUD_HOST_AUTOMATION_SYNC_PATH = "/api/v1/hosts/automation-sync";
10484
10511
  var CLOUD_HOST_AUTHORIZATION_SNAPSHOT_PATH = "/api/v1/hosts/authorization-snapshot";
10485
10512
  var CLOUD_HOST_MAX_SIGNED_RESPONSE_BYTES = 16 * 1024 * 1024;
10486
10513
  var DEFAULT_REQUEST_TIMEOUT_MS3 = 15e3;
@@ -10567,6 +10594,7 @@ function createCloudHostRuntime(options) {
10567
10594
  let lastHeartbeatAt;
10568
10595
  let lastAuthorizationAt;
10569
10596
  let heartbeatFailures = 0;
10597
+ let automationFailures = 0;
10570
10598
  let authorizationFailures = 0;
10571
10599
  let lastAuthorizationIssue;
10572
10600
  let running = false;
@@ -10576,6 +10604,8 @@ function createCloudHostRuntime(options) {
10576
10604
  let heartbeatInFlight;
10577
10605
  let authorizationInFlight;
10578
10606
  const capabilities2 = () => [...new Set(typeof options.capabilities === "function" ? options.capabilities() : options.capabilities)].sort();
10607
+ const automationWebhooks = () => (typeof options.automationWebhooks === "function" ? options.automationWebhooks() : options.automationWebhooks ?? []).map((registration) => CloudAutomationWebhookRegistrationSchema.parse(registration)).sort((left, right) => left.hookId.localeCompare(right.hookId));
10608
+ const automationAcknowledgements = /* @__PURE__ */ new Set();
10579
10609
  const request = async (path, init) => {
10580
10610
  try {
10581
10611
  return await fetchImpl(`${config.controlPlaneOrigin}${path}`, {
@@ -10695,6 +10725,51 @@ function createCloudHostRuntime(options) {
10695
10725
  }
10696
10726
  await response2.body?.cancel().catch(() => void 0);
10697
10727
  lastHeartbeatAt = currentNow;
10728
+ if (state === "ready" && options.automationWebhooks && options.onAutomationInvocation) {
10729
+ try {
10730
+ let deliveryFailed = false;
10731
+ for (let page = 0; page < 4; page += 1) {
10732
+ const acknowledgements = [...automationAcknowledgements].slice(0, 200);
10733
+ const payload = CloudAutomationSyncRequestSchema.parse({
10734
+ v: 1,
10735
+ organizationId: config.organizationId,
10736
+ hostId: config.hostId,
10737
+ instanceId: options.instanceId,
10738
+ registrations: automationWebhooks(),
10739
+ acknowledgements
10740
+ });
10741
+ const automationResponse = await request(CLOUD_HOST_AUTOMATION_SYNC_PATH, {
10742
+ method: "POST",
10743
+ headers: authenticatedHeaders({ "content-type": "application/json", accept: "application/json" }),
10744
+ body: JSON.stringify(payload)
10745
+ });
10746
+ if (automationResponse.status === 404) {
10747
+ await automationResponse.body?.cancel().catch(() => void 0);
10748
+ break;
10749
+ }
10750
+ if (automationResponse.status !== 200) {
10751
+ await boundedResponseText2(automationResponse).catch(() => "");
10752
+ throw responseError(automationResponse);
10753
+ }
10754
+ const parsed = CloudAutomationSyncResponseSchema.parse(
10755
+ JSON.parse(await boundedResponseText2(automationResponse))
10756
+ );
10757
+ for (const id of acknowledgements) automationAcknowledgements.delete(id);
10758
+ for (const invocation of parsed.invocations) {
10759
+ try {
10760
+ await options.onAutomationInvocation(invocation);
10761
+ automationAcknowledgements.add(invocation.id);
10762
+ } catch {
10763
+ deliveryFailed = true;
10764
+ }
10765
+ }
10766
+ if (parsed.invocations.length === 0 && automationAcknowledgements.size === 0) break;
10767
+ }
10768
+ automationFailures = deliveryFailed ? automationFailures + 1 : 0;
10769
+ } catch {
10770
+ automationFailures += 1;
10771
+ }
10772
+ }
10698
10773
  };
10699
10774
  const jittered = (baseMs) => {
10700
10775
  const sample = random();
@@ -10759,6 +10834,7 @@ function createCloudHostRuntime(options) {
10759
10834
  status: () => ({
10760
10835
  running,
10761
10836
  heartbeatFailures,
10837
+ automationFailures,
10762
10838
  authorizationFailures,
10763
10839
  ...lastAuthorizationIssue ? { authorizationIssue: lastAuthorizationIssue } : {},
10764
10840
  ...lastHeartbeatAt === void 0 ? {} : { lastHeartbeatAt },
@@ -14339,6 +14415,48 @@ function normalizeTrigger(value) {
14339
14415
  }
14340
14416
  return { type: "manual" };
14341
14417
  }
14418
+ function validTimeZone(value) {
14419
+ try {
14420
+ new Intl.DateTimeFormat("en-US", { timeZone: value }).format(0);
14421
+ return true;
14422
+ } catch {
14423
+ return false;
14424
+ }
14425
+ }
14426
+ function normalizeConfiguredTriggers(value) {
14427
+ if (!Array.isArray(value) || value.length > 16) throw new Error("invalid automation triggers");
14428
+ const ids = /* @__PURE__ */ new Set();
14429
+ return value.map((candidate) => {
14430
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
14431
+ throw new Error("invalid automation trigger");
14432
+ }
14433
+ const raw = candidate;
14434
+ const id = normalizeId(raw.id, "automation trigger id");
14435
+ if (ids.has(id)) throw new Error("duplicate automation trigger id");
14436
+ ids.add(id);
14437
+ if (typeof raw.enabled !== "boolean") throw new Error("invalid automation trigger state");
14438
+ if (raw.type === "schedule") {
14439
+ if (typeof raw.cron !== "string" || raw.cron.trim().split(/\s+/).length !== 5 || raw.cron.length > 120 || typeof raw.timeZone !== "string" || raw.timeZone.length > 80 || !validTimeZone(raw.timeZone) || raw.missedRunPolicy !== "skip" || Object.keys(raw).some((key) => !["id", "type", "enabled", "cron", "timeZone", "missedRunPolicy"].includes(key))) {
14440
+ throw new Error("invalid schedule trigger");
14441
+ }
14442
+ return {
14443
+ id,
14444
+ type: "schedule",
14445
+ enabled: raw.enabled,
14446
+ cron: raw.cron.trim().replace(/\s+/g, " "),
14447
+ timeZone: raw.timeZone,
14448
+ missedRunPolicy: "skip"
14449
+ };
14450
+ }
14451
+ if (raw.type === "webhook") {
14452
+ if (typeof raw.hookId !== "string" || !/^rcwh_[A-Za-z0-9_-]{24,80}$/.test(raw.hookId) || typeof raw.secretHash !== "string" || !/^[a-f0-9]{64}$/.test(raw.secretHash) || Object.keys(raw).some((key) => !["id", "type", "enabled", "hookId", "secretHash"].includes(key))) {
14453
+ throw new Error("invalid webhook trigger");
14454
+ }
14455
+ return { id, type: "webhook", enabled: raw.enabled, hookId: raw.hookId, secretHash: raw.secretHash };
14456
+ }
14457
+ throw new Error("invalid automation trigger");
14458
+ });
14459
+ }
14342
14460
  function normalizeRuntimeOptions(value) {
14343
14461
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid runtime options");
14344
14462
  let encoded;
@@ -14365,7 +14483,8 @@ function normalizeCreate2(input) {
14365
14483
  cwd: normalizeCwd2(input.cwd),
14366
14484
  instruction: normalizeInstruction(input.instruction),
14367
14485
  runtimeOptions: normalizeRuntimeOptions(input.runtimeOptions ?? {}),
14368
- trigger: normalizeTrigger(input.trigger ?? { type: "manual" })
14486
+ trigger: normalizeTrigger(input.trigger ?? { type: "manual" }),
14487
+ triggers: normalizeConfiguredTriggers(input.triggers ?? [])
14369
14488
  };
14370
14489
  }
14371
14490
  function applyUpdate(current, input, now) {
@@ -14379,7 +14498,8 @@ function applyUpdate(current, input, now) {
14379
14498
  cwd: input.cwd ?? current.cwd,
14380
14499
  instruction: input.instruction ?? current.instruction,
14381
14500
  runtimeOptions: input.runtimeOptions ?? current.runtimeOptions,
14382
- trigger: input.trigger ?? current.trigger
14501
+ trigger: input.trigger ?? current.trigger,
14502
+ triggers: input.triggers ?? current.triggers
14383
14503
  };
14384
14504
  if (Object.values(input).some((value) => value === void 0) || typeof candidate.enabled !== "boolean") {
14385
14505
  throw new Error("invalid automation update");
@@ -14401,9 +14521,12 @@ function createMemoryStore8(opts) {
14401
14521
  const removedDefinitions = /* @__PURE__ */ new Set();
14402
14522
  const runs = /* @__PURE__ */ new Map();
14403
14523
  const runInputSnapshots = /* @__PURE__ */ new Map();
14524
+ const activities = /* @__PURE__ */ new Map();
14525
+ const triggerCursors = /* @__PURE__ */ new Map();
14404
14526
  const nodeOwners = /* @__PURE__ */ new Map();
14405
14527
  const generateAutomationId = opts.generateAutomationId ?? (() => randomId4("rca2"));
14406
14528
  const generateRunId = opts.generateRunId ?? (() => randomId4("rcar"));
14529
+ const generateActivityId = opts.generateActivityId ?? (() => randomId4("rcae"));
14407
14530
  return {
14408
14531
  mode: "memory-fallback",
14409
14532
  getNodeOwner(nodeId) {
@@ -14489,8 +14612,8 @@ function createMemoryStore8(opts) {
14489
14612
  const snapshot = runInputSnapshots.get(id);
14490
14613
  return snapshot ? clone9(snapshot) : void 0;
14491
14614
  },
14492
- getRunByInvocationId(invocationId) {
14493
- const run = [...runs.values()].find((candidate) => candidate.invocationId === invocationId);
14615
+ getRunByInvocationId(invocationId2) {
14616
+ const run = [...runs.values()].find((candidate) => candidate.invocationId === invocationId2);
14494
14617
  return run ? clone9(run) : void 0;
14495
14618
  },
14496
14619
  getRunBySessionId(sessionId) {
@@ -14588,11 +14711,43 @@ function createMemoryStore8(opts) {
14588
14711
  const bounded = Math.max(1, Math.min(1e3, Math.trunc(limit)));
14589
14712
  return [...runs.values()].filter((run) => !automationId || run.automationId === automationId).sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)).slice(0, bounded).map(clone9);
14590
14713
  },
14714
+ getTriggerCursor(triggerId) {
14715
+ return triggerCursors.get(normalizeId(triggerId, "automation trigger id"));
14716
+ },
14717
+ setTriggerCursor(triggerId, minute) {
14718
+ if (!Number.isSafeInteger(minute) || minute < 0) throw new Error("invalid automation trigger cursor");
14719
+ triggerCursors.set(normalizeId(triggerId, "automation trigger id"), minute);
14720
+ },
14721
+ createActivity(input, now = Date.now()) {
14722
+ const id = normalizeId(generateActivityId(), "automation activity id");
14723
+ if (activities.has(id)) throw new Error("automation activity id already exists");
14724
+ const activity = { id, ...clone9(input), createdAt: now, updatedAt: now };
14725
+ activities.set(id, activity);
14726
+ return clone9(activity);
14727
+ },
14728
+ getActivityByInvocationId(invocationId2) {
14729
+ const normalized = normalizeId(invocationId2, "invocation id");
14730
+ const activity = [...activities.values()].find((candidate) => candidate.invocationId === normalized);
14731
+ return activity ? clone9(activity) : void 0;
14732
+ },
14733
+ updateActivity(id, input, now = Date.now()) {
14734
+ const current = activities.get(id);
14735
+ if (!current) return void 0;
14736
+ const next = { ...current, ...clone9(input), updatedAt: now };
14737
+ activities.set(id, next);
14738
+ return clone9(next);
14739
+ },
14740
+ listActivities(automationId, limit = 100) {
14741
+ const bounded = Math.max(1, Math.min(1e3, Math.trunc(limit)));
14742
+ return [...activities.values()].filter((activity) => !automationId || activity.automationId === automationId).sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id)).slice(0, bounded).map(clone9);
14743
+ },
14591
14744
  close() {
14592
14745
  definitions.clear();
14593
14746
  removedDefinitions.clear();
14594
14747
  runs.clear();
14595
14748
  runInputSnapshots.clear();
14749
+ activities.clear();
14750
+ triggerCursors.clear();
14596
14751
  nodeOwners.clear();
14597
14752
  }
14598
14753
  };
@@ -14609,7 +14764,11 @@ function definitionFromRow(row) {
14609
14764
  cwd: row.cwd,
14610
14765
  instruction: row.instruction,
14611
14766
  runtimeOptions: JSON.parse(row.runtime_options_json),
14612
- trigger: normalizeTrigger(JSON.parse(row.trigger_json)),
14767
+ trigger: { type: "manual" },
14768
+ triggers: (() => {
14769
+ const parsed = JSON.parse(row.trigger_json);
14770
+ return Array.isArray(parsed) ? normalizeConfiguredTriggers(parsed) : [];
14771
+ })(),
14613
14772
  revision: row.revision,
14614
14773
  createdAt: row.created_at,
14615
14774
  updatedAt: row.updated_at
@@ -14642,6 +14801,22 @@ function runInputSnapshotFromRow(row) {
14642
14801
  bootstrapState: row.bootstrap_state
14643
14802
  };
14644
14803
  }
14804
+ function activityFromRow(row) {
14805
+ return {
14806
+ id: row.id,
14807
+ automationId: row.automation_id,
14808
+ triggerId: row.trigger_id,
14809
+ source: row.source,
14810
+ status: row.status,
14811
+ invocationId: row.invocation_id,
14812
+ ...row.scheduled_for === null ? {} : { scheduledFor: row.scheduled_for },
14813
+ ...row.missed_count === null ? {} : { missedCount: row.missed_count },
14814
+ ...row.run_id === null ? {} : { runId: row.run_id },
14815
+ ...row.failure_code === null ? {} : { failureCode: row.failure_code },
14816
+ createdAt: row.created_at,
14817
+ updatedAt: row.updated_at
14818
+ };
14819
+ }
14645
14820
  function openSessionAutomationStore(opts) {
14646
14821
  let Database;
14647
14822
  try {
@@ -14705,6 +14880,27 @@ function openSessionAutomationStore(opts) {
14705
14880
  bootstrap_state TEXT NOT NULL DEFAULT 'pending'
14706
14881
  CHECK(bootstrap_state IN ('pending','submitting','submitted'))
14707
14882
  );
14883
+ CREATE TABLE IF NOT EXISTS session_automation_trigger_cursors (
14884
+ trigger_id TEXT PRIMARY KEY,
14885
+ minute INTEGER NOT NULL CHECK(minute >= 0),
14886
+ updated_at INTEGER NOT NULL
14887
+ );
14888
+ CREATE TABLE IF NOT EXISTS session_automation_activities (
14889
+ id TEXT PRIMARY KEY,
14890
+ automation_id TEXT NOT NULL REFERENCES session_automations(id) ON DELETE CASCADE,
14891
+ trigger_id TEXT NOT NULL,
14892
+ source TEXT NOT NULL CHECK(source IN ('schedule','webhook')),
14893
+ status TEXT NOT NULL CHECK(status IN ('queued','started','failed','missed','expired')),
14894
+ invocation_id TEXT NOT NULL UNIQUE,
14895
+ scheduled_for INTEGER,
14896
+ missed_count INTEGER CHECK(missed_count IS NULL OR missed_count > 0),
14897
+ run_id TEXT REFERENCES session_automation_runs(id) ON DELETE SET NULL,
14898
+ failure_code TEXT,
14899
+ created_at INTEGER NOT NULL,
14900
+ updated_at INTEGER NOT NULL
14901
+ );
14902
+ CREATE INDEX IF NOT EXISTS session_automation_activities_definition_idx
14903
+ ON session_automation_activities(automation_id, created_at DESC);
14708
14904
  `);
14709
14905
  const automationColumns = db.pragma("table_info(session_automations)");
14710
14906
  if (!automationColumns.some((column) => column.name === "deleted_at")) {
@@ -14718,6 +14914,7 @@ function openSessionAutomationStore(opts) {
14718
14914
  }
14719
14915
  const generateAutomationId = opts.generateAutomationId ?? (() => randomId4("rca2"));
14720
14916
  const generateRunId = opts.generateRunId ?? (() => randomId4("rcar"));
14917
+ const generateActivityId = opts.generateActivityId ?? (() => randomId4("rcae"));
14721
14918
  const getDefinition = db.prepare("SELECT * FROM session_automations WHERE id = ? AND deleted_at IS NULL");
14722
14919
  const getDefinitionIncludingRemoved = db.prepare("SELECT * FROM session_automations WHERE id = ?");
14723
14920
  const listAll = db.prepare("SELECT * FROM session_automations WHERE deleted_at IS NULL ORDER BY updated_at DESC, id");
@@ -14744,6 +14941,30 @@ function openSessionAutomationStore(opts) {
14744
14941
  const listRunsForAutomation = db.prepare(
14745
14942
  "SELECT * FROM session_automation_runs WHERE automation_id = ? ORDER BY created_at DESC, id DESC LIMIT ?"
14746
14943
  );
14944
+ const getTriggerCursorStatement = db.prepare(
14945
+ "SELECT minute FROM session_automation_trigger_cursors WHERE trigger_id = ?"
14946
+ );
14947
+ const setTriggerCursorStatement = db.prepare(
14948
+ `INSERT INTO session_automation_trigger_cursors(trigger_id,minute,updated_at) VALUES(?,?,?)
14949
+ ON CONFLICT(trigger_id) DO UPDATE SET minute=excluded.minute,updated_at=excluded.updated_at`
14950
+ );
14951
+ const getActivity = db.prepare("SELECT * FROM session_automation_activities WHERE id = ?");
14952
+ const getActivityByInvocationId = db.prepare("SELECT * FROM session_automation_activities WHERE invocation_id = ?");
14953
+ const insertActivity = db.prepare(
14954
+ `INSERT INTO session_automation_activities(
14955
+ id,automation_id,trigger_id,source,status,invocation_id,scheduled_for,missed_count,run_id,failure_code,
14956
+ created_at,updated_at
14957
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`
14958
+ );
14959
+ const updateActivityStatement = db.prepare(
14960
+ `UPDATE session_automation_activities SET status=?,run_id=?,failure_code=?,updated_at=? WHERE id=?`
14961
+ );
14962
+ const listActivitiesAll = db.prepare(
14963
+ "SELECT * FROM session_automation_activities ORDER BY created_at DESC, id DESC LIMIT ?"
14964
+ );
14965
+ const listActivitiesForAutomation = db.prepare(
14966
+ "SELECT * FROM session_automation_activities WHERE automation_id = ? ORDER BY created_at DESC, id DESC LIMIT ?"
14967
+ );
14747
14968
  return {
14748
14969
  mode: "sqlite",
14749
14970
  getNodeOwner(nodeId) {
@@ -14783,7 +15004,7 @@ function openSessionAutomationStore(opts) {
14783
15004
  definition.cwd,
14784
15005
  definition.instruction,
14785
15006
  JSON.stringify(definition.runtimeOptions),
14786
- JSON.stringify(definition.trigger),
15007
+ JSON.stringify(definition.triggers),
14787
15008
  definition.revision,
14788
15009
  definition.createdAt,
14789
15010
  definition.updatedAt
@@ -14805,7 +15026,7 @@ function openSessionAutomationStore(opts) {
14805
15026
  next.cwd,
14806
15027
  next.instruction,
14807
15028
  JSON.stringify(next.runtimeOptions),
14808
- JSON.stringify(next.trigger),
15029
+ JSON.stringify(next.triggers),
14809
15030
  next.revision,
14810
15031
  next.updatedAt,
14811
15032
  next.id,
@@ -14866,8 +15087,8 @@ function openSessionAutomationStore(opts) {
14866
15087
  const row = selectRunInputSnapshot.get(id);
14867
15088
  return row ? runInputSnapshotFromRow(row) : void 0;
14868
15089
  },
14869
- getRunByInvocationId(invocationId) {
14870
- const row = getRunByInvocationId.get(invocationId);
15090
+ getRunByInvocationId(invocationId2) {
15091
+ const row = getRunByInvocationId.get(invocationId2);
14871
15092
  return row ? runFromRow(row) : void 0;
14872
15093
  },
14873
15094
  getRunBySessionId(sessionId) {
@@ -14978,6 +15199,63 @@ function openSessionAutomationStore(opts) {
14978
15199
  const rows = automationId ? listRunsForAutomation.all(automationId, bounded) : listRunsAll.all(bounded);
14979
15200
  return rows.map(runFromRow);
14980
15201
  },
15202
+ getTriggerCursor(triggerId) {
15203
+ const row = getTriggerCursorStatement.get(normalizeId(triggerId, "automation trigger id"));
15204
+ return row?.minute;
15205
+ },
15206
+ setTriggerCursor(triggerId, minute) {
15207
+ if (!Number.isSafeInteger(minute) || minute < 0) throw new Error("invalid automation trigger cursor");
15208
+ setTriggerCursorStatement.run(normalizeId(triggerId, "automation trigger id"), minute, Date.now());
15209
+ },
15210
+ createActivity(input, now = Date.now()) {
15211
+ const activity = {
15212
+ id: normalizeId(generateActivityId(), "automation activity id"),
15213
+ ...input,
15214
+ automationId: normalizeId(input.automationId, "automation id"),
15215
+ triggerId: normalizeId(input.triggerId, "automation trigger id"),
15216
+ invocationId: normalizeId(input.invocationId, "invocation id"),
15217
+ createdAt: now,
15218
+ updatedAt: now
15219
+ };
15220
+ insertActivity.run(
15221
+ activity.id,
15222
+ activity.automationId,
15223
+ activity.triggerId,
15224
+ activity.source,
15225
+ activity.status,
15226
+ activity.invocationId,
15227
+ activity.scheduledFor ?? null,
15228
+ activity.missedCount ?? null,
15229
+ activity.runId ?? null,
15230
+ activity.failureCode ?? null,
15231
+ activity.createdAt,
15232
+ activity.updatedAt
15233
+ );
15234
+ return activity;
15235
+ },
15236
+ getActivityByInvocationId(invocationId2) {
15237
+ const row = getActivityByInvocationId.get(normalizeId(invocationId2, "invocation id"));
15238
+ return row ? activityFromRow(row) : void 0;
15239
+ },
15240
+ updateActivity(id, input, now = Date.now()) {
15241
+ const row = getActivity.get(id);
15242
+ if (!row) return void 0;
15243
+ const current = activityFromRow(row);
15244
+ const next = { ...current, ...input, updatedAt: now };
15245
+ const result = updateActivityStatement.run(
15246
+ next.status,
15247
+ next.runId ?? null,
15248
+ next.failureCode ?? null,
15249
+ next.updatedAt,
15250
+ current.id
15251
+ );
15252
+ return result.changes === 1 ? next : void 0;
15253
+ },
15254
+ listActivities(automationId, limit = 100) {
15255
+ const bounded = Math.max(1, Math.min(1e3, Math.trunc(limit)));
15256
+ const rows = automationId ? listActivitiesForAutomation.all(automationId, bounded) : listActivitiesAll.all(bounded);
15257
+ return rows.map(activityFromRow);
15258
+ },
14981
15259
  close() {
14982
15260
  db.close();
14983
15261
  }
@@ -15141,7 +15419,7 @@ function resolveVapidKeys(opts) {
15141
15419
  }
15142
15420
 
15143
15421
  // src/transport.ts
15144
- import { createHash as createHash10, randomBytes as randomBytes17, randomUUID as randomUUID7, timingSafeEqual as timingSafeEqual6 } from "crypto";
15422
+ import { createHash as createHash10, randomBytes as randomBytes17, randomUUID as randomUUID8, timingSafeEqual as timingSafeEqual6 } from "crypto";
15145
15423
  import { basename as pathBasename, join as join17, resolve as resolvePath } from "path";
15146
15424
  import { createReadStream } from "fs";
15147
15425
  import Fastify2 from "fastify";
@@ -15322,9 +15600,206 @@ function terminalSharedDir(opts) {
15322
15600
  return join11(terminalSharedBase(opts), opts.sessionId);
15323
15601
  }
15324
15602
 
15603
+ // src/automation-trigger-engine.ts
15604
+ import { randomUUID as randomUUID5 } from "crypto";
15605
+ function parsePart(part, min, max, normalize) {
15606
+ const [rangePart, stepPart] = part.split("/");
15607
+ const step = stepPart === void 0 ? 1 : Number(stepPart);
15608
+ if (!Number.isSafeInteger(step) || step < 1 || !rangePart) throw new Error("invalid cron field");
15609
+ let start;
15610
+ let end;
15611
+ if (rangePart === "*") {
15612
+ start = min;
15613
+ end = max;
15614
+ } else if (rangePart.includes("-")) {
15615
+ const pair = rangePart.split("-");
15616
+ if (pair.length !== 2) throw new Error("invalid cron field");
15617
+ start = Number(pair[0]);
15618
+ end = Number(pair[1]);
15619
+ } else {
15620
+ start = Number(rangePart);
15621
+ end = start;
15622
+ }
15623
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < min || end > max || start > end) {
15624
+ throw new Error("invalid cron field");
15625
+ }
15626
+ const values = [];
15627
+ for (let value = start; value <= end; value += step) values.push(normalize ? normalize(value) : value);
15628
+ return values;
15629
+ }
15630
+ function parseField(source, min, max, normalize) {
15631
+ const values = /* @__PURE__ */ new Set();
15632
+ for (const part of source.split(",")) {
15633
+ for (const value of parsePart(part, min, max, normalize)) values.add(value);
15634
+ }
15635
+ return { any: source === "*", values };
15636
+ }
15637
+ function validateCronExpression(expression) {
15638
+ const normalized = expression.trim().replace(/\s+/g, " ");
15639
+ const fields = normalized.split(" ");
15640
+ if (fields.length !== 5) throw new Error("cron must contain five fields");
15641
+ parseField(fields[0], 0, 59);
15642
+ parseField(fields[1], 0, 23);
15643
+ parseField(fields[2], 1, 31);
15644
+ parseField(fields[3], 1, 12);
15645
+ parseField(fields[4], 0, 7, (value) => value === 7 ? 0 : value);
15646
+ return normalized;
15647
+ }
15648
+ function zonedParts(time, timeZone) {
15649
+ const parts = new Intl.DateTimeFormat("en-US", {
15650
+ timeZone,
15651
+ minute: "2-digit",
15652
+ hour: "2-digit",
15653
+ hourCycle: "h23",
15654
+ day: "2-digit",
15655
+ month: "2-digit",
15656
+ weekday: "short"
15657
+ }).formatToParts(time);
15658
+ const byType = new Map(parts.map((part) => [part.type, part.value]));
15659
+ const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(byType.get("weekday") ?? "");
15660
+ return {
15661
+ minute: Number(byType.get("minute")),
15662
+ hour: Number(byType.get("hour")),
15663
+ day: Number(byType.get("day")),
15664
+ month: Number(byType.get("month")),
15665
+ weekday
15666
+ };
15667
+ }
15668
+ function cronMatches(expression, timeZone, time) {
15669
+ const fields = validateCronExpression(expression).split(" ");
15670
+ const minute = parseField(fields[0], 0, 59);
15671
+ const hour = parseField(fields[1], 0, 23);
15672
+ const day = parseField(fields[2], 1, 31);
15673
+ const month = parseField(fields[3], 1, 12);
15674
+ const weekday = parseField(fields[4], 0, 7, (value2) => value2 === 7 ? 0 : value2);
15675
+ const value = zonedParts(time, timeZone);
15676
+ const dayMatches = day.values.has(value.day);
15677
+ const weekdayMatches = weekday.values.has(value.weekday);
15678
+ const calendarMatches = day.any && weekday.any ? true : day.any ? weekdayMatches : weekday.any ? dayMatches : dayMatches || weekdayMatches;
15679
+ return minute.values.has(value.minute) && hour.values.has(value.hour) && month.values.has(value.month) && calendarMatches;
15680
+ }
15681
+ function invocationId(source, automationId, triggerId, nonce) {
15682
+ const safeNonce = nonce.replace(/[^A-Za-z0-9._:-]/g, "_").slice(0, 96);
15683
+ return `rci_${source}_${automationId}_${triggerId}_${safeNonce}`.slice(0, 256);
15684
+ }
15685
+ function createAutomationTriggerEngine(options) {
15686
+ const now = options.now ?? Date.now;
15687
+ const interval = options.setInterval ?? globalThis.setInterval;
15688
+ const clearInterval2 = options.clearInterval ?? globalThis.clearInterval;
15689
+ const concurrency = Number.isSafeInteger(options.concurrency) && (options.concurrency ?? 0) > 0 ? options.concurrency : 2;
15690
+ const pending = [];
15691
+ const pendingIds = /* @__PURE__ */ new Set();
15692
+ const inFlightIds = /* @__PURE__ */ new Set();
15693
+ let active = 0;
15694
+ let ticking = false;
15695
+ let timer;
15696
+ const queue = (activity, deferDrain = false) => {
15697
+ if (activity.status !== "queued" || pendingIds.has(activity.id) || inFlightIds.has(activity.id)) return;
15698
+ pendingIds.add(activity.id);
15699
+ pending.push(activity);
15700
+ pending.sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id));
15701
+ if (!deferDrain) drain();
15702
+ };
15703
+ const drain = () => {
15704
+ while (active < concurrency && pending.length > 0) {
15705
+ const activity = pending.shift();
15706
+ pendingIds.delete(activity.id);
15707
+ inFlightIds.add(activity.id);
15708
+ active += 1;
15709
+ void options.execute(activity).then(({ runId }) => options.store.updateActivity(activity.id, { status: "started", runId })).catch(
15710
+ () => options.store.updateActivity(activity.id, { status: "failed", failureCode: "TRIGGER_EXECUTION_FAILED" })
15711
+ ).finally(() => {
15712
+ inFlightIds.delete(activity.id);
15713
+ active -= 1;
15714
+ drain();
15715
+ });
15716
+ }
15717
+ };
15718
+ const createQueued = (automation, trigger, source, nonce, scheduledFor) => {
15719
+ const id = invocationId(source, automation.id, trigger.id, nonce);
15720
+ const existing = options.store.getActivityByInvocationId(id);
15721
+ if (existing) {
15722
+ queue(existing);
15723
+ return existing;
15724
+ }
15725
+ const activity = options.store.createActivity({
15726
+ automationId: automation.id,
15727
+ triggerId: trigger.id,
15728
+ source,
15729
+ status: "queued",
15730
+ invocationId: id,
15731
+ ...scheduledFor === void 0 ? {} : { scheduledFor }
15732
+ });
15733
+ queue(activity);
15734
+ return activity;
15735
+ };
15736
+ const tick = async (at = now()) => {
15737
+ if (ticking) return;
15738
+ ticking = true;
15739
+ try {
15740
+ const currentMinute = Math.floor(at / 6e4);
15741
+ for (const automation of options.store.list()) {
15742
+ if (!automation.enabled) continue;
15743
+ for (const trigger of automation.triggers) {
15744
+ if (trigger.type !== "schedule" || !trigger.enabled) continue;
15745
+ const previous = options.store.getTriggerCursor(trigger.id) ?? currentMinute - 1;
15746
+ if (previous >= currentMinute) continue;
15747
+ const firstMinute = Math.max(previous + 1, currentMinute - 43200);
15748
+ const matches = [];
15749
+ for (let minute = firstMinute; minute <= currentMinute; minute += 1) {
15750
+ if (cronMatches(trigger.cron, trigger.timeZone, minute * 6e4)) matches.push(minute);
15751
+ }
15752
+ const missed = matches.filter((minute) => minute < currentMinute);
15753
+ if (missed.length > 0) {
15754
+ const missedInvocation = invocationId("schedule", automation.id, trigger.id, `missed-${currentMinute}`);
15755
+ const exists = options.store.listActivities(automation.id, 1e3).some((activity) => activity.invocationId === missedInvocation);
15756
+ if (!exists) {
15757
+ options.store.createActivity({
15758
+ automationId: automation.id,
15759
+ triggerId: trigger.id,
15760
+ source: "schedule",
15761
+ status: "missed",
15762
+ invocationId: missedInvocation,
15763
+ scheduledFor: missed[missed.length - 1] * 6e4,
15764
+ missedCount: missed.length,
15765
+ failureCode: "NODE_OFFLINE_MISSED_SCHEDULE"
15766
+ });
15767
+ }
15768
+ }
15769
+ if (matches.includes(currentMinute)) {
15770
+ createQueued(automation, trigger, "schedule", String(currentMinute), currentMinute * 6e4);
15771
+ }
15772
+ options.store.setTriggerCursor(trigger.id, currentMinute);
15773
+ }
15774
+ }
15775
+ } finally {
15776
+ ticking = false;
15777
+ }
15778
+ };
15779
+ return {
15780
+ start() {
15781
+ if (timer) return;
15782
+ for (const activity of options.store.listActivities(void 0, 1e3)) queue(activity, true);
15783
+ drain();
15784
+ void tick();
15785
+ timer = interval(() => void tick(), 15e3);
15786
+ },
15787
+ stop() {
15788
+ if (!timer) return;
15789
+ clearInterval2(timer);
15790
+ timer = void 0;
15791
+ },
15792
+ tick,
15793
+ enqueueWebhook(automation, trigger, externalInvocationId) {
15794
+ if (trigger.type !== "webhook") throw new Error("invalid webhook trigger");
15795
+ return createQueued(automation, trigger, "webhook", externalInvocationId ?? randomUUID5());
15796
+ }
15797
+ };
15798
+ }
15799
+
15325
15800
  // src/updater.ts
15326
15801
  import { spawn as nodeSpawn } from "child_process";
15327
- import { randomUUID as randomUUID6 } from "crypto";
15802
+ import { randomUUID as randomUUID7 } from "crypto";
15328
15803
  import {
15329
15804
  chmodSync as nodeChmodSync,
15330
15805
  existsSync as nodeExistsSync,
@@ -15338,7 +15813,7 @@ import { fileURLToPath as fileURLToPath2 } from "url";
15338
15813
 
15339
15814
  // src/managed-runtime.ts
15340
15815
  import { spawn as spawn3 } from "child_process";
15341
- import { randomUUID as randomUUID5 } from "crypto";
15816
+ import { randomUUID as randomUUID6 } from "crypto";
15342
15817
  import {
15343
15818
  chmodSync as chmodSync4,
15344
15819
  existsSync as existsSync5,
@@ -15615,7 +16090,7 @@ function compareVersions(a, b) {
15615
16090
  }
15616
16091
  function atomicWrite(path, value, mode = 384) {
15617
16092
  mkdirSync3(dirname9(path), { recursive: true, mode: 448 });
15618
- const temp = `${path}.${process.pid}.${randomUUID5()}.tmp`;
16093
+ const temp = `${path}.${process.pid}.${randomUUID6()}.tmp`;
15619
16094
  writeFileSync8(temp, value, { mode });
15620
16095
  chmodSync4(temp, mode);
15621
16096
  renameSync5(temp, path);
@@ -15759,7 +16234,7 @@ async function npmIntegrity(npmCommand, packageName, version, nodePath, log) {
15759
16234
  return parsed;
15760
16235
  }
15761
16236
  async function smokeServer(serverEntry, dataDir, nodePath, log) {
15762
- const smokeDir = join13(tmpdir(), `roamcode-smoke-${process.pid}-${randomUUID5()}`);
16237
+ const smokeDir = join13(tmpdir(), `roamcode-smoke-${process.pid}-${randomUUID6()}`);
15763
16238
  mkdirSync3(smokeDir, { recursive: true, mode: 448 });
15764
16239
  let output = "";
15765
16240
  const child = spawn3(nodePath, [serverEntry], {
@@ -15767,7 +16242,7 @@ async function smokeServer(serverEntry, dataDir, nodePath, log) {
15767
16242
  ...process.env,
15768
16243
  PORT: "0",
15769
16244
  BIND_ADDRESS: "127.0.0.1",
15770
- ACCESS_TOKEN: `rc-smoke-${randomUUID5()}`,
16245
+ ACCESS_TOKEN: `rc-smoke-${randomUUID6()}`,
15771
16246
  ROAMCODE_DATA_DIR: smokeDir,
15772
16247
  RC_TMUX_SOCKET: `rc-smoke-${process.pid}`,
15773
16248
  ROAMCODE_INSTALL_ROOT: "",
@@ -15810,7 +16285,7 @@ async function smokeServer(serverEntry, dataDir, nodePath, log) {
15810
16285
  }
15811
16286
  }
15812
16287
  function replaceSymlink(link2, target) {
15813
- const temp = `${link2}.${process.pid}.${randomUUID5()}.new`;
16288
+ const temp = `${link2}.${process.pid}.${randomUUID6()}.new`;
15814
16289
  try {
15815
16290
  unlinkSync6(temp);
15816
16291
  } catch {
@@ -15826,7 +16301,7 @@ function trimLog(log) {
15826
16301
  async function installManagedRelease(opts) {
15827
16302
  if (!isStableVersion(opts.version)) throw new Error(`invalid release version: ${opts.version}`);
15828
16303
  const now = opts.now ?? Date.now;
15829
- const operationId = opts.operationId ?? randomUUID5();
16304
+ const operationId = opts.operationId ?? randomUUID6();
15830
16305
  const nodePath = opts.nodePath ?? process.execPath;
15831
16306
  const npmCommand = opts.npmCommand ?? process.env.npm_execpath ?? "npm";
15832
16307
  const expectedIntegrities = opts.expectedIntegrities ?? (opts.expectedIntegrity ? { roamcode: opts.expectedIntegrity } : {});
@@ -15835,7 +16310,7 @@ async function installManagedRelease(opts) {
15835
16310
  mkdirSync3(paths.releases, { recursive: true, mode: 448 });
15836
16311
  mkdirSync3(paths.staging, { recursive: true, mode: 448 });
15837
16312
  const release = join13(paths.releases, opts.version);
15838
- const stage = join13(paths.staging, `${opts.version}-${process.pid}-${randomUUID5()}`);
16313
+ const stage = join13(paths.staging, `${opts.version}-${process.pid}-${randomUUID6()}`);
15839
16314
  let logText = "";
15840
16315
  const log = (line) => {
15841
16316
  logText += line.endsWith("\n") ? line : `${line}
@@ -15970,7 +16445,7 @@ async function installManagedRelease(opts) {
15970
16445
  }
15971
16446
 
15972
16447
  // src/updater.ts
15973
- var RUNNING_VERSION = "1.3.0" ? "1.3.0".replace(/^v/, "") : packageVersion();
16448
+ var RUNNING_VERSION = "1.4.0" ? "1.4.0".replace(/^v/, "") : packageVersion();
15974
16449
  var RUNNING_BUILD = RUNNING_VERSION;
15975
16450
  var RELEASES_API = "https://api.github.com/repos/burakgon/roamcode/releases?per_page=100";
15976
16451
  var RELEASE_MANIFEST_ASSET = "roamcode-release.json";
@@ -16271,7 +16746,7 @@ var Updater = class {
16271
16746
  const path = join14(this.dataDir, "update-status.json");
16272
16747
  const value = JSON.stringify(status, null, 2) + "\n";
16273
16748
  if (this.fs.renameSync) {
16274
- const temp = `${path}.${process.pid}.${randomUUID6()}.tmp`;
16749
+ const temp = `${path}.${process.pid}.${randomUUID7()}.tmp`;
16275
16750
  this.fs.writeFileSync(temp, value, 384);
16276
16751
  this.fs.renameSync(temp, path);
16277
16752
  } else {
@@ -16334,7 +16809,7 @@ var Updater = class {
16334
16809
  return { started: false, reason: error instanceof Error ? error.message : String(error) };
16335
16810
  }
16336
16811
  }
16337
- const operationId = randomUUID6();
16812
+ const operationId = randomUUID7();
16338
16813
  const status = {
16339
16814
  operationId,
16340
16815
  state: "starting",
@@ -19758,9 +20233,12 @@ function buildOpenApiDocument(options) {
19758
20233
  responses: {
19759
20234
  "201": response("Created session automation", {
19760
20235
  type: "object",
19761
- required: ["automation"],
20236
+ required: ["automation", "webhookSecrets"],
19762
20237
  additionalProperties: false,
19763
- properties: { automation: ref("SessionAutomationDefinition") }
20238
+ properties: {
20239
+ automation: ref("SessionAutomationDefinition"),
20240
+ webhookSecrets: { type: "array", items: ref("SessionAutomationWebhookSecret") }
20241
+ }
19764
20242
  }),
19765
20243
  "404": response("Node or runtime not found", ref("Error")),
19766
20244
  ...errorResponses
@@ -19789,9 +20267,12 @@ function buildOpenApiDocument(options) {
19789
20267
  responses: {
19790
20268
  "200": response("Updated session automation", {
19791
20269
  type: "object",
19792
- required: ["automation"],
20270
+ required: ["automation", "webhookSecrets"],
19793
20271
  additionalProperties: false,
19794
- properties: { automation: ref("SessionAutomationDefinition") }
20272
+ properties: {
20273
+ automation: ref("SessionAutomationDefinition"),
20274
+ webhookSecrets: { type: "array", items: ref("SessionAutomationWebhookSecret") }
20275
+ }
19795
20276
  }),
19796
20277
  "404": response("Automation, node, or runtime not found", ref("Error")),
19797
20278
  ...errorResponses
@@ -19807,6 +20288,76 @@ function buildOpenApiDocument(options) {
19807
20288
  }
19808
20289
  }
19809
20290
  },
20291
+ "/api/v2/automations/{automationId}/activity": {
20292
+ get: {
20293
+ operationId: "listSessionAutomationActivityV2",
20294
+ description: "Returns durable schedule and webhook delivery activity, including explicit missed schedules.",
20295
+ parameters: [
20296
+ idParameter("automationId"),
20297
+ { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 100, default: 25 } }
20298
+ ],
20299
+ responses: {
20300
+ "200": response("Bounded automation trigger activity", {
20301
+ type: "object",
20302
+ required: ["activities"],
20303
+ additionalProperties: false,
20304
+ properties: { activities: { type: "array", items: ref("SessionAutomationActivity") } }
20305
+ }),
20306
+ "404": response("Automation not found", ref("Error")),
20307
+ ...errorResponses
20308
+ }
20309
+ }
20310
+ },
20311
+ "/api/v2/automations/{automationId}/triggers/{triggerId}/secret": {
20312
+ post: {
20313
+ operationId: "rotateSessionAutomationWebhookSecretV2",
20314
+ parameters: [idParameter("automationId"), idParameter("triggerId"), idempotency],
20315
+ requestBody: {
20316
+ required: true,
20317
+ content: json({
20318
+ type: "object",
20319
+ required: ["expectedRevision"],
20320
+ additionalProperties: false,
20321
+ properties: { expectedRevision: { type: "integer", minimum: 1 } }
20322
+ })
20323
+ },
20324
+ responses: {
20325
+ "200": response("Rotated webhook secret shown exactly once", {
20326
+ type: "object",
20327
+ required: ["automation", "webhookSecret"],
20328
+ additionalProperties: false,
20329
+ properties: {
20330
+ automation: ref("SessionAutomationDefinition"),
20331
+ webhookSecret: ref("SessionAutomationWebhookSecret")
20332
+ }
20333
+ }),
20334
+ "404": response("Automation or webhook trigger not found", ref("Error")),
20335
+ ...errorResponses
20336
+ }
20337
+ }
20338
+ },
20339
+ "/api/v2/automation-hooks/{hookId}": {
20340
+ post: {
20341
+ operationId: "signalSessionAutomationWebhookV2",
20342
+ description: "Queues a signal-only webhook. The request body is discarded and never becomes task instruction or stored prompt content.",
20343
+ security: [{ webhookAuth: [] }],
20344
+ parameters: [idParameter("hookId")],
20345
+ requestBody: {
20346
+ required: false,
20347
+ content: json({ description: "Ignored signal payload", nullable: true })
20348
+ },
20349
+ responses: {
20350
+ "202": response("Webhook signal accepted", {
20351
+ type: "object",
20352
+ required: ["accepted"],
20353
+ additionalProperties: false,
20354
+ properties: { accepted: { const: true } }
20355
+ }),
20356
+ "401": response("Unknown hook or invalid webhook secret", ref("Error")),
20357
+ "429": response("Webhook rate limit reached", ref("Error"))
20358
+ }
20359
+ }
20360
+ },
19810
20361
  "/api/v2/automations/{automationId}/runs": {
19811
20362
  get: {
19812
20363
  operationId: "listSessionAutomationRunsV2",
@@ -19864,7 +20415,8 @@ function buildOpenApiDocument(options) {
19864
20415
  },
19865
20416
  components: {
19866
20417
  securitySchemes: {
19867
- bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "RoamCode device credential" }
20418
+ bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "RoamCode device credential" },
20419
+ webhookAuth: { type: "http", scheme: "bearer", bearerFormat: "One-time-shown webhook secret" }
19868
20420
  },
19869
20421
  schemas: {
19870
20422
  Error: {
@@ -20838,6 +21390,62 @@ function buildOpenApiDocument(options) {
20838
21390
  additionalProperties: false,
20839
21391
  properties: { type: { const: "manual" } }
20840
21392
  },
21393
+ SessionAutomationConfiguredTrigger: {
21394
+ oneOf: [
21395
+ {
21396
+ type: "object",
21397
+ required: ["id", "type", "enabled", "cron", "timeZone", "missedRunPolicy"],
21398
+ additionalProperties: false,
21399
+ properties: {
21400
+ id: { type: "string", minLength: 1, maxLength: 256 },
21401
+ type: { const: "schedule" },
21402
+ enabled: { type: "boolean" },
21403
+ cron: { type: "string", minLength: 9, maxLength: 120 },
21404
+ timeZone: { type: "string", minLength: 1, maxLength: 80 },
21405
+ missedRunPolicy: { const: "skip" }
21406
+ }
21407
+ },
21408
+ {
21409
+ type: "object",
21410
+ required: ["id", "type", "enabled", "hookId"],
21411
+ additionalProperties: false,
21412
+ properties: {
21413
+ id: { type: "string", minLength: 1, maxLength: 256 },
21414
+ type: { const: "webhook" },
21415
+ enabled: { type: "boolean" },
21416
+ hookId: { type: "string", pattern: "^rcwh_[A-Za-z0-9_-]{24,80}$" }
21417
+ }
21418
+ }
21419
+ ]
21420
+ },
21421
+ SessionAutomationTriggerInput: {
21422
+ oneOf: [
21423
+ {
21424
+ type: "object",
21425
+ required: ["type", "enabled", "cron", "timeZone", "missedRunPolicy"],
21426
+ additionalProperties: false,
21427
+ properties: {
21428
+ id: { type: "string", minLength: 1, maxLength: 256 },
21429
+ type: { const: "schedule" },
21430
+ enabled: { type: "boolean" },
21431
+ cron: { type: "string", minLength: 9, maxLength: 120 },
21432
+ timeZone: { type: "string", minLength: 1, maxLength: 80 },
21433
+ missedRunPolicy: { const: "skip" }
21434
+ }
21435
+ },
21436
+ {
21437
+ type: "object",
21438
+ required: ["type", "enabled"],
21439
+ additionalProperties: false,
21440
+ properties: {
21441
+ id: { type: "string", minLength: 1, maxLength: 256 },
21442
+ type: { const: "webhook" },
21443
+ enabled: { type: "boolean" },
21444
+ hookId: { type: "string", pattern: "^rcwh_[A-Za-z0-9_-]{24,80}$" }
21445
+ }
21446
+ }
21447
+ ]
21448
+ },
20841
21449
  SessionAutomationDefinition: {
20842
21450
  type: "object",
20843
21451
  required: [
@@ -20850,6 +21458,7 @@ function buildOpenApiDocument(options) {
20850
21458
  "provider",
20851
21459
  "cwd",
20852
21460
  "trigger",
21461
+ "triggers",
20853
21462
  "instruction",
20854
21463
  "runtimeOptions",
20855
21464
  "revision",
@@ -20867,6 +21476,7 @@ function buildOpenApiDocument(options) {
20867
21476
  provider: { type: "string", pattern: "^[a-z][a-z0-9-]{0,63}$" },
20868
21477
  cwd: { type: "string", minLength: 1 },
20869
21478
  trigger: ref("SessionAutomationTrigger"),
21479
+ triggers: { type: "array", maxItems: 16, items: ref("SessionAutomationConfiguredTrigger") },
20870
21480
  instruction: {
20871
21481
  type: "string",
20872
21482
  minLength: 1,
@@ -20891,6 +21501,7 @@ function buildOpenApiDocument(options) {
20891
21501
  agentRuntimeId: { type: "string", pattern: "^runtime_[A-Za-z0-9_-]{24}$" },
20892
21502
  cwd: { type: "string", minLength: 1 },
20893
21503
  trigger: ref("SessionAutomationTrigger"),
21504
+ triggers: { type: "array", maxItems: 16, items: ref("SessionAutomationTriggerInput") },
20894
21505
  instruction: {
20895
21506
  type: "string",
20896
21507
  minLength: 1,
@@ -20914,6 +21525,7 @@ function buildOpenApiDocument(options) {
20914
21525
  agentRuntimeId: { type: "string", pattern: "^runtime_[A-Za-z0-9_-]{24}$" },
20915
21526
  cwd: { type: "string", minLength: 1 },
20916
21527
  trigger: ref("SessionAutomationTrigger"),
21528
+ triggers: { type: "array", maxItems: 16, items: ref("SessionAutomationTriggerInput") },
20917
21529
  instruction: {
20918
21530
  type: "string",
20919
21531
  minLength: 1,
@@ -20924,6 +21536,36 @@ function buildOpenApiDocument(options) {
20924
21536
  runtimeOptions: { type: "object", additionalProperties: true, "x-maxBytes": 65536 }
20925
21537
  }
20926
21538
  },
21539
+ SessionAutomationWebhookSecret: {
21540
+ type: "object",
21541
+ required: ["triggerId", "hookId", "secret", "path"],
21542
+ additionalProperties: false,
21543
+ properties: {
21544
+ triggerId: { type: "string", minLength: 1, maxLength: 256 },
21545
+ hookId: { type: "string", pattern: "^rcwh_[A-Za-z0-9_-]{24,80}$" },
21546
+ secret: { type: "string", pattern: "^rcws_[A-Za-z0-9_-]{43}$" },
21547
+ path: { type: "string", pattern: "^/api/v2/automation-hooks/rcwh_[A-Za-z0-9_-]{24,80}$" }
21548
+ }
21549
+ },
21550
+ SessionAutomationActivity: {
21551
+ type: "object",
21552
+ required: ["id", "automationId", "triggerId", "source", "status", "invocationId", "createdAt", "updatedAt"],
21553
+ additionalProperties: false,
21554
+ properties: {
21555
+ id: { type: "string", minLength: 1, maxLength: 256 },
21556
+ automationId: { type: "string", minLength: 1, maxLength: 256 },
21557
+ triggerId: { type: "string", minLength: 1, maxLength: 256 },
21558
+ source: { enum: ["schedule", "webhook"] },
21559
+ status: { enum: ["queued", "started", "failed", "missed", "expired"] },
21560
+ invocationId: { type: "string", minLength: 1, maxLength: 256 },
21561
+ scheduledFor: { type: "integer", minimum: 0 },
21562
+ missedCount: { type: "integer", minimum: 1 },
21563
+ runId: { type: "string", minLength: 1, maxLength: 256 },
21564
+ failureCode: { type: "string", pattern: "^[A-Z][A-Z0-9_]{0,79}$" },
21565
+ createdAt: { type: "integer", minimum: 0 },
21566
+ updatedAt: { type: "integer", minimum: 0 }
21567
+ }
21568
+ },
20927
21569
  SessionAutomationRun: {
20928
21570
  description: "Privacy-bounded run state with its exact execution binding; terminal transcript, instruction, and provider detail are omitted.",
20929
21571
  type: "object",
@@ -21358,9 +22000,9 @@ function cloudStatusResponse(status) {
21358
22000
  };
21359
22001
  }
21360
22002
  const authorization = status.authorization;
21361
- const syncFailures = status.authorizationFailures > 0 || status.heartbeatFailures > 0;
22003
+ const syncFailures = status.authorizationFailures > 0 || status.heartbeatFailures > 0 || status.automationFailures > 0;
21362
22004
  const syncState = authorization.status === "expired" ? "expired" : authorization.status === "pending" ? "pending" : authorization.status === "unavailable" ? syncFailures ? "degraded" : "syncing" : syncFailures ? "degraded" : "healthy";
21363
- const action = status.authorizationIssue === "credential-rejected" || status.authorizationIssue === "trust-expired" ? "reauthorize-host" : status.authorizationIssue === "invalid-control-plane-response" || status.authorizationIssue === "authorization-verification-failed" ? "contact-organization-admin" : status.authorizationIssue === "connectivity" ? "check-host-connectivity" : status.heartbeatFailures > 0 ? "check-host-connectivity" : authorization.status === "unavailable" ? "wait-for-cloud-sync" : authorization.status === "pending" ? "wait-for-authorization-activation" : authorization.status === "expired" ? "check-host-connectivity" : "none";
22005
+ const action = status.authorizationIssue === "credential-rejected" || status.authorizationIssue === "trust-expired" ? "reauthorize-host" : status.authorizationIssue === "invalid-control-plane-response" || status.authorizationIssue === "authorization-verification-failed" ? "contact-organization-admin" : status.authorizationIssue === "connectivity" ? "check-host-connectivity" : status.heartbeatFailures > 0 || status.automationFailures > 0 ? "check-host-connectivity" : authorization.status === "unavailable" ? "wait-for-cloud-sync" : authorization.status === "pending" ? "wait-for-authorization-activation" : authorization.status === "expired" ? "check-host-connectivity" : "none";
21364
22006
  return {
21365
22007
  v: 1,
21366
22008
  mode: "managed",
@@ -21797,6 +22439,7 @@ function createServer(config, deps = {}) {
21797
22439
  enabled: config.rateLimitRpm > 0
21798
22440
  });
21799
22441
  const pairingRateLimiter = new RateLimiter({ capacity: 30, windowMs: 6e4, burst: 10 });
22442
+ const automationWebhookRateLimiter = new RateLimiter({ capacity: 120, windowMs: 6e4, burst: 30 });
21800
22443
  const fsService = new FsService({ root: config.fsRoot });
21801
22444
  const terminalSharedRoot = terminalSharedBase({ dataDir, fsRoot: config.fsRoot });
21802
22445
  const backfilledFileSessions = /* @__PURE__ */ new Set();
@@ -21816,7 +22459,7 @@ function createServer(config, deps = {}) {
21816
22459
  const now = Date.now();
21817
22460
  const media = attachmentMedia(file.filename);
21818
22461
  store.putFile({
21819
- id: randomUUID7(),
22462
+ id: randomUUID8(),
21820
22463
  sessionId,
21821
22464
  direction: "sent",
21822
22465
  storage: "managed",
@@ -21888,6 +22531,7 @@ function createServer(config, deps = {}) {
21888
22531
  }
21889
22532
  const relayInternalCapability = randomBytes17(32).toString("base64url");
21890
22533
  const authenticatedPrincipals = /* @__PURE__ */ new WeakMap();
22534
+ const automationWebhookRequests = /* @__PURE__ */ new WeakMap();
21891
22535
  const hostPrincipal = () => ({
21892
22536
  actorType: config.accessToken ? "host" : "local",
21893
22537
  actorId: commandStore.getHost().id,
@@ -22034,6 +22678,28 @@ function createServer(config, deps = {}) {
22034
22678
  if (request.is404 && isPublicPath(path)) return;
22035
22679
  }
22036
22680
  if (path === "/health") return;
22681
+ const automationHookMatch = /^\/api\/v2\/automation-hooks\/(rcwh_[A-Za-z0-9_-]{24,80})$/.exec(path);
22682
+ if (request.method === "POST" && automationHookMatch && !hasEncodedSep(request.url)) {
22683
+ const limit = automationWebhookRateLimiter.take(request.ip);
22684
+ if (!limit.allowed) {
22685
+ reply.header("retry-after", String(limit.retryAfterSeconds)).code(429).send({ error: "rate limited" });
22686
+ return;
22687
+ }
22688
+ const hookId = automationHookMatch[1];
22689
+ const match = sessionAutomationStore.list().flatMap((automation) => automation.triggers.map((trigger) => ({ automation, trigger }))).find(
22690
+ (entry) => entry.automation.enabled && entry.trigger.type === "webhook" && entry.trigger.enabled && entry.trigger.hookId === hookId
22691
+ );
22692
+ const secret = extractBearerToken(request.headers.authorization);
22693
+ const presented = secret ? createHash10("sha256").update(secret).digest() : Buffer.alloc(0);
22694
+ const expected = match?.trigger.type === "webhook" ? Buffer.from(match.trigger.secretHash, "hex") : Buffer.alloc(32);
22695
+ if (!match || presented.length !== expected.length || !timingSafeEqual6(presented, expected)) {
22696
+ reply.code(401).send({ error: "unauthorized" });
22697
+ return;
22698
+ }
22699
+ automationWebhookRequests.set(request, match);
22700
+ authenticatedPrincipals.set(request, hostPrincipal());
22701
+ return;
22702
+ }
22037
22703
  if (request.method === "POST" && path === "/pairing/claim" && !hasEncodedSep(request.url)) {
22038
22704
  const originAllowed2 = isOriginAllowed(request.headers.origin, request.headers.host, {
22039
22705
  publicUrl: config.publicUrl,
@@ -22424,7 +23090,7 @@ function createServer(config, deps = {}) {
22424
23090
  return;
22425
23091
  }
22426
23092
  const principal = authenticatedPrincipals.get(request) ?? hostPrincipal();
22427
- const holderId = `ws:${randomUUID7()}`;
23093
+ const holderId = `ws:${randomUUID8()}`;
22428
23094
  let leaseId;
22429
23095
  let lastLeaseRevision = 0;
22430
23096
  const sendLeaseState = (reason, revision) => {
@@ -22719,7 +23385,7 @@ function createServer(config, deps = {}) {
22719
23385
  reply.code(400).send({ code: "INVALID_PROVIDER", error: "Invalid provider" });
22720
23386
  return;
22721
23387
  }
22722
- const id = requestedSessionId ?? randomUUID7();
23388
+ const id = requestedSessionId ?? randomUUID8();
22723
23389
  const existingMeta = requestedSessionId ? terminalManager.get(id) : void 0;
22724
23390
  if (!terminalAvailable) {
22725
23391
  reply.code(400).send({
@@ -23386,7 +24052,7 @@ function createServer(config, deps = {}) {
23386
24052
  const peerIdempotencyKey = (request, peerId) => {
23387
24053
  const actor = actorForRequest(request);
23388
24054
  const provided = request.headers["idempotency-key"];
23389
- const key = typeof provided === "string" ? provided : randomUUID7();
24055
+ const key = typeof provided === "string" ? provided : randomUUID8();
23390
24056
  return `peer-${createHash10("sha256").update(`${peerId}\0${actor.actorType}\0${actor.actorId}\0${key}`).digest("base64url")}`;
23391
24057
  };
23392
24058
  const workspaceAllowedByPeer = (peer, workspaceId) => typeof workspaceId === "string" && (peer.allowedWorkspaceIds === null || peer.allowedWorkspaceIds.includes(workspaceId));
@@ -25477,7 +26143,7 @@ data: ${JSON.stringify(data)}
25477
26143
  });
25478
26144
  return;
25479
26145
  }
25480
- holderId = `api-once:${randomUUID7()}`;
26146
+ holderId = `api-once:${randomUUID8()}`;
25481
26147
  const acquired = inputLeases.acquire(request.params.id, holderId, principal);
25482
26148
  if (acquired.status === "denied") {
25483
26149
  reply.code(409).send({ code: "INPUT_LEASE_REQUIRED", error: "terminal input is already controlled" });
@@ -25631,7 +26297,7 @@ data: ${JSON.stringify(data)}
25631
26297
  return;
25632
26298
  }
25633
26299
  const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
25634
- const id = randomUUID7();
26300
+ const id = randomUUID8();
25635
26301
  const now = Date.now();
25636
26302
  const media = attachmentMedia(described.name);
25637
26303
  const stored2 = {
@@ -26085,9 +26751,78 @@ data: ${JSON.stringify(data)}
26085
26751
  const owner = currentNodeOwner();
26086
26752
  return automation && automation.owner.type === owner.type && automation.owner.id === owner.id ? automation : void 0;
26087
26753
  };
26754
+ const projectAutomationDefinition = (automation) => ({
26755
+ ...automation,
26756
+ triggers: automation.triggers.map((trigger) => {
26757
+ if (trigger.type !== "webhook") return trigger;
26758
+ const { secretHash, ...publicTrigger } = trigger;
26759
+ void secretHash;
26760
+ return publicTrigger;
26761
+ })
26762
+ });
26763
+ const newTriggerId = () => `rct_${randomBytes17(12).toString("base64url")}`;
26764
+ const newWebhookHookId = () => `rcwh_${randomBytes17(24).toString("base64url")}`;
26765
+ const newWebhookSecret = () => `rcws_${randomBytes17(32).toString("base64url")}`;
26766
+ const prepareAutomationTriggers = (value, current) => {
26767
+ if (!Array.isArray(value) || value.length > 16) throw new Error("invalid automation triggers");
26768
+ const existing = new Map(current?.triggers.map((trigger) => [trigger.id, trigger]) ?? []);
26769
+ const ids = /* @__PURE__ */ new Set();
26770
+ const webhookSecrets = [];
26771
+ const triggers = value.map((candidate) => {
26772
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
26773
+ throw new Error("invalid automation trigger");
26774
+ }
26775
+ const raw = candidate;
26776
+ const requestedId = typeof raw.id === "string" && /^[A-Za-z0-9._:-]{1,256}$/.test(raw.id) ? raw.id : void 0;
26777
+ const id = requestedId ?? newTriggerId();
26778
+ if (ids.has(id)) throw new Error("duplicate automation trigger id");
26779
+ ids.add(id);
26780
+ if (typeof raw.enabled !== "boolean") throw new Error("invalid automation trigger state");
26781
+ if (raw.type === "schedule") {
26782
+ if (typeof raw.cron !== "string" || typeof raw.timeZone !== "string" || raw.timeZone.length > 80 || raw.missedRunPolicy !== "skip" || Object.keys(raw).some(
26783
+ (key) => !["id", "type", "enabled", "cron", "timeZone", "missedRunPolicy"].includes(key)
26784
+ )) {
26785
+ throw new Error("invalid schedule trigger");
26786
+ }
26787
+ new Intl.DateTimeFormat("en-US", { timeZone: raw.timeZone }).format(0);
26788
+ return {
26789
+ id,
26790
+ type: "schedule",
26791
+ enabled: raw.enabled,
26792
+ cron: validateCronExpression(raw.cron),
26793
+ timeZone: raw.timeZone,
26794
+ missedRunPolicy: "skip"
26795
+ };
26796
+ }
26797
+ if (raw.type === "webhook") {
26798
+ if (Object.keys(raw).some((key) => !["id", "type", "enabled", "hookId"].includes(key))) {
26799
+ throw new Error("invalid webhook trigger");
26800
+ }
26801
+ const previous = existing.get(id);
26802
+ if (previous?.type === "webhook") {
26803
+ if (raw.hookId !== void 0 && raw.hookId !== previous.hookId) {
26804
+ throw new Error("webhook identity cannot be replaced");
26805
+ }
26806
+ return { ...previous, enabled: raw.enabled };
26807
+ }
26808
+ const hookId = newWebhookHookId();
26809
+ const secret = newWebhookSecret();
26810
+ webhookSecrets.push({ triggerId: id, hookId, secret, path: `/api/v2/automation-hooks/${hookId}` });
26811
+ return {
26812
+ id,
26813
+ type: "webhook",
26814
+ enabled: raw.enabled,
26815
+ hookId,
26816
+ secretHash: createHash10("sha256").update(secret).digest("hex")
26817
+ };
26818
+ }
26819
+ throw new Error("invalid automation trigger");
26820
+ });
26821
+ return { triggers, webhookSecrets };
26822
+ };
26088
26823
  const automationInvocationIdentity = (request, automationId) => {
26089
26824
  const idempotency2 = mutationContexts.get(request)?.idempotency;
26090
- if (!idempotency2) return { invocationId: randomUUID7(), sessionId: randomUUID7() };
26825
+ if (!idempotency2) return { invocationId: randomUUID8(), sessionId: randomUUID8() };
26091
26826
  const actor = actorForRequest(request);
26092
26827
  const digest3 = createHash10("sha256").update("roamcode-automation-invocation-v1\0").update(JSON.stringify([actor.actorType, actor.actorId, idempotency2.key, idempotency2.fingerprint, automationId])).digest("hex");
26093
26828
  const uuidHex = digest3.slice(0, 32).split("");
@@ -26356,10 +27091,13 @@ data: ${JSON.stringify(data)}
26356
27091
  "cwd",
26357
27092
  "instruction",
26358
27093
  "runtimeOptions",
26359
- "trigger"
27094
+ "trigger",
27095
+ "triggers"
26360
27096
  ]);
26361
27097
  const automationUpdateKeys = /* @__PURE__ */ new Set([...automationCreateKeys, "expectedRevision"]);
26362
- app.get("/api/v2/automations", async () => ({ automations: sessionAutomationStore.list(currentNodeOwner()) }));
27098
+ app.get("/api/v2/automations", async () => ({
27099
+ automations: sessionAutomationStore.list(currentNodeOwner()).map(projectAutomationDefinition)
27100
+ }));
26363
27101
  app.post("/api/v2/automations", { bodyLimit: 128 * 1024 }, async (request, reply) => {
26364
27102
  if (!validObjectBody(request.body)) return sendInvalidAutomation(reply);
26365
27103
  if ("owner" in request.body || "provider" in request.body) {
@@ -26379,15 +27117,17 @@ data: ${JSON.stringify(data)}
26379
27117
  );
26380
27118
  if (!target) return;
26381
27119
  try {
27120
+ const prepared = prepareAutomationTriggers(request.body.triggers ?? [], void 0);
26382
27121
  const automation = sessionAutomationStore.create({
26383
27122
  owner: currentNodeOwner(),
26384
27123
  name: request.body.name,
26385
27124
  ...request.body.enabled === void 0 ? {} : { enabled: request.body.enabled },
26386
27125
  ...target,
26387
27126
  instruction: request.body.instruction,
26388
- ...request.body.trigger === void 0 ? { trigger: { type: "manual" } } : { trigger: request.body.trigger }
27127
+ ...request.body.trigger === void 0 ? { trigger: { type: "manual" } } : { trigger: request.body.trigger },
27128
+ triggers: prepared.triggers
26389
27129
  });
26390
- reply.code(201).send({ automation });
27130
+ reply.code(201).send({ automation: projectAutomationDefinition(automation), webhookSecrets: prepared.webhookSecrets });
26391
27131
  } catch {
26392
27132
  sendInvalidAutomation(reply);
26393
27133
  }
@@ -26398,7 +27138,7 @@ data: ${JSON.stringify(data)}
26398
27138
  reply.code(404).send({ code: "SESSION_AUTOMATION_NOT_FOUND", error: "automation not found" });
26399
27139
  return;
26400
27140
  }
26401
- return { automation };
27141
+ return { automation: projectAutomationDefinition(automation) };
26402
27142
  });
26403
27143
  app.patch(
26404
27144
  "/api/v2/automations/:automationId",
@@ -26436,18 +27176,20 @@ data: ${JSON.stringify(data)}
26436
27176
  ...target
26437
27177
  };
26438
27178
  try {
27179
+ const prepared = prepareAutomationTriggers(request.body.triggers ?? current.triggers, current);
27180
+ input.triggers = prepared.triggers;
26439
27181
  const automation = sessionAutomationStore.update(current.id, input, request.body.expectedRevision);
26440
27182
  if (!automation) {
26441
27183
  reply.code(404).send({ code: "SESSION_AUTOMATION_NOT_FOUND", error: "automation not found" });
26442
27184
  return;
26443
27185
  }
26444
- return { automation };
27186
+ return { automation: projectAutomationDefinition(automation), webhookSecrets: prepared.webhookSecrets };
26445
27187
  } catch (error) {
26446
27188
  if (error instanceof SessionAutomationRevisionConflictError) {
26447
27189
  reply.code(409).send({
26448
27190
  code: "SESSION_AUTOMATION_REVISION_CONFLICT",
26449
27191
  error: "automation state changed",
26450
- current: error.current
27192
+ current: projectAutomationDefinition(error.current)
26451
27193
  });
26452
27194
  return;
26453
27195
  }
@@ -26462,6 +27204,70 @@ data: ${JSON.stringify(data)}
26462
27204
  }
26463
27205
  reply.code(204).send();
26464
27206
  });
27207
+ app.get(
27208
+ "/api/v2/automations/:automationId/activity",
27209
+ async (request, reply) => {
27210
+ const automation = ownedAutomationIncludingRemoved(request.params.automationId);
27211
+ if (!automation) {
27212
+ reply.code(404).send({ code: "SESSION_AUTOMATION_NOT_FOUND", error: "automation not found" });
27213
+ return;
27214
+ }
27215
+ const limit = request.query.limit === void 0 ? 25 : Number(request.query.limit);
27216
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
27217
+ reply.code(400).send({ code: "INVALID_AUTOMATION_ACTIVITY_LIMIT", error: "limit must be between 1 and 100" });
27218
+ return;
27219
+ }
27220
+ return { activities: sessionAutomationStore.listActivities(automation.id, limit) };
27221
+ }
27222
+ );
27223
+ app.post(
27224
+ "/api/v2/automations/:automationId/triggers/:triggerId/secret",
27225
+ { bodyLimit: 1024 },
27226
+ async (request, reply) => {
27227
+ const current = ownedAutomation(request.params.automationId);
27228
+ if (!current) {
27229
+ reply.code(404).send({ code: "SESSION_AUTOMATION_NOT_FOUND", error: "automation not found" });
27230
+ return;
27231
+ }
27232
+ if (!validObjectBody(request.body) || !hasOnlyKeys(request.body, /* @__PURE__ */ new Set(["expectedRevision"])) || !Number.isSafeInteger(request.body.expectedRevision) || request.body.expectedRevision !== current.revision) {
27233
+ return sendInvalidAutomation(reply, "a current expectedRevision is required");
27234
+ }
27235
+ const selected = current.triggers.find(
27236
+ (trigger) => trigger.id === request.params.triggerId && trigger.type === "webhook"
27237
+ );
27238
+ if (!selected || selected.type !== "webhook") {
27239
+ reply.code(404).send({ code: "AUTOMATION_TRIGGER_NOT_FOUND", error: "webhook trigger not found" });
27240
+ return;
27241
+ }
27242
+ const secret = newWebhookSecret();
27243
+ const triggers = current.triggers.map(
27244
+ (trigger) => trigger.id === selected.id ? { ...selected, secretHash: createHash10("sha256").update(secret).digest("hex") } : trigger
27245
+ );
27246
+ try {
27247
+ const automation = sessionAutomationStore.update(current.id, { triggers }, current.revision);
27248
+ if (!automation) throw new Error("automation disappeared");
27249
+ return {
27250
+ automation: projectAutomationDefinition(automation),
27251
+ webhookSecret: {
27252
+ triggerId: selected.id,
27253
+ hookId: selected.hookId,
27254
+ secret,
27255
+ path: `/api/v2/automation-hooks/${selected.hookId}`
27256
+ }
27257
+ };
27258
+ } catch (error) {
27259
+ if (error instanceof SessionAutomationRevisionConflictError) {
27260
+ reply.code(409).send({
27261
+ code: "SESSION_AUTOMATION_REVISION_CONFLICT",
27262
+ error: "automation state changed",
27263
+ current: projectAutomationDefinition(error.current)
27264
+ });
27265
+ return;
27266
+ }
27267
+ sendInvalidAutomation(reply);
27268
+ }
27269
+ }
27270
+ );
26465
27271
  app.get(
26466
27272
  "/api/v2/automations/:automationId/runs",
26467
27273
  async (request, reply) => {
@@ -26690,6 +27496,46 @@ data: ${JSON.stringify(data)}
26690
27496
  );
26691
27497
  }
26692
27498
  );
27499
+ const parsedAutomationConcurrency = Number.parseInt(process.env.ROAMCODE_AUTOMATION_CONCURRENCY ?? "2", 10);
27500
+ const automationTriggerEngine = createAutomationTriggerEngine({
27501
+ store: sessionAutomationStore,
27502
+ concurrency: Number.isSafeInteger(parsedAutomationConcurrency) && parsedAutomationConcurrency > 0 ? parsedAutomationConcurrency : 2,
27503
+ execute: async (activity) => {
27504
+ const response2 = await app.inject({
27505
+ method: "POST",
27506
+ url: `/api/v2/automations/${encodeURIComponent(activity.automationId)}/runs`,
27507
+ headers: {
27508
+ "idempotency-key": activity.id,
27509
+ ...config.accessToken ? { authorization: `Bearer ${config.accessToken}` } : {}
27510
+ }
27511
+ });
27512
+ const body = (() => {
27513
+ try {
27514
+ return response2.json();
27515
+ } catch {
27516
+ return {};
27517
+ }
27518
+ })();
27519
+ if (response2.statusCode !== 201 || typeof body.run?.id !== "string") {
27520
+ throw new Error("automation trigger execution failed");
27521
+ }
27522
+ return { runId: body.run.id };
27523
+ }
27524
+ });
27525
+ app.post(
27526
+ "/api/v2/automation-hooks/:hookId",
27527
+ { bodyLimit: 64 * 1024 },
27528
+ async (request, reply) => {
27529
+ const authorized = automationWebhookRequests.get(request);
27530
+ if (!authorized || authorized.trigger.type !== "webhook" || authorized.trigger.hookId !== request.params.hookId) {
27531
+ reply.code(401).send({ error: "unauthorized" });
27532
+ return;
27533
+ }
27534
+ automationTriggerEngine.enqueueWebhook(authorized.automation, authorized.trigger);
27535
+ reply.code(202).send({ accepted: true });
27536
+ }
27537
+ );
27538
+ app.addHook("onReady", async () => automationTriggerEngine.start());
26693
27539
  app.get("/providers", async () => {
26694
27540
  return { providers: await readProviderAvailability() };
26695
27541
  });
@@ -27048,7 +27894,7 @@ data: ${JSON.stringify(data)}
27048
27894
  reply.code(400).send({ error: "no file field in the upload" });
27049
27895
  return;
27050
27896
  }
27051
- const id = randomUUID7();
27897
+ const id = randomUUID8();
27052
27898
  try {
27053
27899
  const dir = await fsService.ensureDirWithinRoot(
27054
27900
  `${terminalSharedDir({ dataDir, fsRoot: config.fsRoot, sessionId })}/${id}`
@@ -27100,7 +27946,7 @@ data: ${JSON.stringify(data)}
27100
27946
  reply.code(400).send({ error: "an edited image is required" });
27101
27947
  return;
27102
27948
  }
27103
- const id = randomUUID7();
27949
+ const id = randomUUID8();
27104
27950
  try {
27105
27951
  const dir = await fsService.ensureDirWithinRoot(
27106
27952
  `${terminalSharedDir({ dataDir, fsRoot: config.fsRoot, sessionId: source.sessionId })}/${id}`
@@ -27226,6 +28072,7 @@ data: ${JSON.stringify(data)}
27226
28072
  );
27227
28073
  if (deps.webDir) registerStatic(app, { webDir: deps.webDir });
27228
28074
  app.addHook("onClose", async () => {
28075
+ automationTriggerEngine.stop();
27229
28076
  clearInterval(sharedSweepTimer);
27230
28077
  inputLeases.close();
27231
28078
  unsubscribeAutomations();
@@ -27321,6 +28168,25 @@ data: ${JSON.stringify(data)}
27321
28168
  authGate,
27322
28169
  terminalManager,
27323
28170
  terminalAvailable,
28171
+ automationWebhookRegistrations: () => sessionAutomationStore.list().flatMap(
28172
+ (automation) => automation.triggers.filter((trigger) => trigger.type === "webhook").map((trigger) => ({
28173
+ hookId: trigger.hookId,
28174
+ automationId: automation.id,
28175
+ triggerId: trigger.id,
28176
+ secretHash: trigger.secretHash,
28177
+ enabled: automation.enabled && trigger.enabled
28178
+ }))
28179
+ ),
28180
+ acceptCloudAutomationInvocation: async (invocation) => {
28181
+ const automation = sessionAutomationStore.get(invocation.automationId);
28182
+ const trigger = automation?.triggers.find(
28183
+ (candidate) => candidate.type === "webhook" && candidate.id === invocation.triggerId && candidate.hookId === invocation.hookId
28184
+ );
28185
+ if (!automation?.enabled || !trigger?.enabled || trigger.type !== "webhook") {
28186
+ throw new Error("managed automation webhook is no longer active");
28187
+ }
28188
+ automationTriggerEngine.enqueueWebhook(automation, trigger, invocation.id);
28189
+ },
27324
28190
  inputLeases,
27325
28191
  teamStore,
27326
28192
  policyStore,
@@ -27345,7 +28211,7 @@ function fileEntityTag(size, mtimeMs) {
27345
28211
  import { pathToFileURL as pathToFileURL2, fileURLToPath as fileURLToPath4 } from "url";
27346
28212
  import { join as join20 } from "path";
27347
28213
  import { existsSync as existsSync7 } from "fs";
27348
- import { randomUUID as randomUUID11 } from "crypto";
28214
+ import { randomUUID as randomUUID12 } from "crypto";
27349
28215
 
27350
28216
  // src/push-store.ts
27351
28217
  import { createRequire as createRequire15 } from "module";
@@ -27667,7 +28533,7 @@ function createUsageService(opts) {
27667
28533
 
27668
28534
  // src/claude-auth-service.ts
27669
28535
  import { spawn as nodeSpawn2 } from "child_process";
27670
- import { randomUUID as randomUUID8 } from "crypto";
28536
+ import { randomUUID as randomUUID9 } from "crypto";
27671
28537
  function parseAuthStatus(stdout) {
27672
28538
  try {
27673
28539
  const o = JSON.parse(stdout);
@@ -27744,7 +28610,7 @@ var ClaudeAuthService = class {
27744
28610
  reject(err instanceof Error ? err : new Error("failed to spawn claude"));
27745
28611
  return;
27746
28612
  }
27747
- const id = randomUUID8();
28613
+ const id = randomUUID9();
27748
28614
  let settled = false;
27749
28615
  let buf = "";
27750
28616
  const onData = (d) => {
@@ -29106,7 +29972,7 @@ var CodexMetadataService = class {
29106
29972
 
29107
29973
  // src/providers/claude-metadata-service.ts
29108
29974
  import { execFile as nodeExecFile2, spawn as nodeSpawn3 } from "child_process";
29109
- import { randomUUID as randomUUID9 } from "crypto";
29975
+ import { randomUUID as randomUUID10 } from "crypto";
29110
29976
  import { StringDecoder } from "string_decoder";
29111
29977
  var SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:/\u005b\u005d-]*$/;
29112
29978
  var MAX_MODELS = 64;
@@ -29314,7 +30180,7 @@ function createClaudeMetadataRunner(options) {
29314
30180
  reject(metadataUnavailable2());
29315
30181
  return;
29316
30182
  }
29317
- const requestId = `roamcode-models-${randomUUID9()}`;
30183
+ const requestId = `roamcode-models-${randomUUID10()}`;
29318
30184
  const decoder = new StringDecoder("utf8");
29319
30185
  let stdoutBuffer = "";
29320
30186
  let outputBytes = 0;
@@ -29566,7 +30432,7 @@ var CodexLatestService = class {
29566
30432
  import { execFile } from "child_process";
29567
30433
  import { constants as constants10 } from "fs";
29568
30434
  import { access as access2, chmod, copyFile, link, realpath as realpath7, rename as rename3, rm as rm2, rmdir as rmdir2, stat as stat7 } from "fs/promises";
29569
- import { randomUUID as randomUUID10 } from "crypto";
30435
+ import { randomUUID as randomUUID11 } from "crypto";
29570
30436
  import { basename as basename5, delimiter, dirname as dirname12, isAbsolute as isAbsolute9, join as join19, sep as sep5 } from "path";
29571
30437
  var CODEX_EXECUTABLE_PROBE_TIMEOUT_MS = 5e3;
29572
30438
  var OPENAI_CODE_SIGNING_TEAM_ID = "2DC432GLL2";
@@ -29676,7 +30542,7 @@ async function removeLegacyManagedCopy(dataDir) {
29676
30542
  }
29677
30543
  async function repairHomebrewExecutable(source, sourceStat, env, deps) {
29678
30544
  if (!await deps.verifyOfficialSignature(source)) return false;
29679
- const nonce = randomUUID10();
30545
+ const nonce = randomUUID11();
29680
30546
  const directory = dirname12(source);
29681
30547
  const name = basename5(source);
29682
30548
  const temporary = join19(directory, `.${name}.roamcode-repair-${nonce}`);
@@ -29906,13 +30772,14 @@ function cloudHostCapabilities(input) {
29906
30772
  return [
29907
30773
  `authorization.v${input.authorizationVersion}`,
29908
30774
  ...input.terminalAvailable ? ["terminal.v1"] : [],
30775
+ ...input.terminalAvailable ? ["automation-schedule.v1", "automation-webhook.v1"] : [],
29909
30776
  ...input.relayEnabled ? ["relay.v1"] : [],
29910
30777
  ...input.managedDeviceEnrollmentEnabled && input.relayEnabled && input.terminalAvailable ? ["managed-device-enrollment.v1"] : []
29911
30778
  ];
29912
30779
  }
29913
30780
  async function startServer(env = process.env, options = {}) {
29914
30781
  const config = loadServerConfig(env);
29915
- const healthInstanceId = randomUUID11();
30782
+ const healthInstanceId = randomUUID12();
29916
30783
  ensureDataDir(config.dataDir);
29917
30784
  const loopback = isLoopbackAddress(config.bindAddress);
29918
30785
  let token;
@@ -30185,6 +31052,7 @@ async function startServer(env = process.env, options = {}) {
30185
31052
  cloudStatus: () => cloudHostRuntime?.status() ?? {
30186
31053
  running: false,
30187
31054
  heartbeatFailures: 0,
31055
+ automationFailures: 0,
30188
31056
  authorizationFailures: 0,
30189
31057
  authorization: cloudAuthorizationStore.getState()
30190
31058
  }
@@ -30217,6 +31085,8 @@ async function startServer(env = process.env, options = {}) {
30217
31085
  relayEnabled: relayConfig !== void 0,
30218
31086
  managedDeviceEnrollmentEnabled: cloudDeviceEnrollmentConfirmer !== void 0 && relayProvisioner !== void 0
30219
31087
  }),
31088
+ automationWebhooks: () => result.automationWebhookRegistrations(),
31089
+ onAutomationInvocation: (invocation) => result.acceptCloudAutomationInvocation(invocation),
30220
31090
  ...relayConfig ? {
30221
31091
  relayHostIdentity: {
30222
31092
  publicKey: relayConfig.hostIdentity.publicKey,
@@ -30372,6 +31242,7 @@ export {
30372
31242
  CLOUD_DEVICE_ENROLLMENT_CONFIRM_PATH,
30373
31243
  CLOUD_DEVICE_ENROLLMENT_HOST_ROUTE,
30374
31244
  CLOUD_HOST_AUTHORIZATION_SNAPSHOT_PATH,
31245
+ CLOUD_HOST_AUTOMATION_SYNC_PATH,
30375
31246
  CLOUD_HOST_CONFIG_FILE,
30376
31247
  CLOUD_HOST_HEARTBEAT_PATH,
30377
31248
  CLOUD_HOST_MAX_SIGNED_RESPONSE_BYTES,