@myagentroam/node 0.9.122 → 0.9.125

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 (31) hide show
  1. package/dist/codex-app-server.js +6 -79
  2. package/dist/connector.js +217 -6
  3. package/dist/database.js +2 -1
  4. package/dist/migrations/v014.js +107 -0
  5. package/dist/runner/abstract-runner.js +11 -5
  6. package/dist/runner/claude-code-runner.js +3 -0
  7. package/dist/runner/codex/conversation-parser.js +142 -2
  8. package/dist/runner/codex/managed-run-controller.js +26 -18
  9. package/dist/runner/codex-runner.js +127 -57
  10. package/dist/runner/opencode-runner.js +14 -12
  11. package/dist/runtime-state.js +6 -1
  12. package/dist/service/continuous-run-coordinator.js +8 -2
  13. package/dist/service/conversation-history-service.js +51 -10
  14. package/dist/service/managed-runner-session-service.js +2 -1
  15. package/dist/service/native-session-projection-service.js +3 -1
  16. package/dist/service/node-control-message-service.js +2 -0
  17. package/dist/service/run-event-service.js +18 -2
  18. package/dist/service/runner-interaction-service.js +9 -8
  19. package/dist/service/scheduled-run-authorization-control.js +72 -0
  20. package/dist/service/scheduled-session-coordinator.js +95 -0
  21. package/dist/service/scheduled-session-recurrence.js +72 -0
  22. package/dist/service/scheduled-session-store.js +234 -0
  23. package/dist/service/scheduled-task-mcp-server.js +179 -0
  24. package/dist/service/scheduled-task-mcp-stdio.js +63 -0
  25. package/dist/service/session-catalog-service.js +3 -1
  26. package/dist/service/session-lifecycle-service.js +6 -0
  27. package/dist/service/session-message-service.js +25 -2
  28. package/dist/service/session-presentation-service.js +19 -2
  29. package/dist/service/session-schedule-service.js +153 -0
  30. package/dist/util/runner-native-session-parsers.js +19 -11
  31. package/package.json +4 -4
@@ -8,7 +8,6 @@ import { homedir } from 'node:os';
8
8
  import { openCodeConversationPage } from './opencode/conversation-parser.js';
9
9
  import { nodeLog } from '../operational.js';
10
10
  const PROFILE_ENTRY_TTL_MS = 30 * 60_000;
11
- const PROFILE_STALE_TTL_MS = 60 * 60_000;
12
11
  const PROFILE_FAILURE_RETRY_MS = 30_000;
13
12
  const PROFILE_REFRESH_TIMEOUT_MS = 90_000;
14
13
  export class OpenCodeRunner extends AbstractRunner {
@@ -21,6 +20,7 @@ export class OpenCodeRunner extends AbstractRunner {
21
20
  profileCache;
22
21
  profileRetryAt = 0;
23
22
  profileRefresh;
23
+ profileRefreshTimer;
24
24
  profileGeneration = 0;
25
25
  execution = new Map();
26
26
  managedTurns = new Map();
@@ -40,7 +40,6 @@ export class OpenCodeRunner extends AbstractRunner {
40
40
  return { ...base, models: [] };
41
41
  }
42
42
  cachedProfile(capabilities, environment = {}) {
43
- this.pruneProfileCaches(Date.now());
44
43
  const cached = this.profileCache;
45
44
  return cached === undefined
46
45
  ? this.profile(capabilities)
@@ -51,7 +50,6 @@ export class OpenCodeRunner extends AbstractRunner {
51
50
  return this.profile(capabilities);
52
51
  }
53
52
  const now = Date.now();
54
- this.pruneProfileCaches(now);
55
53
  const cached = this.profileCache;
56
54
  if (cached !== undefined && cached.expiresAt > now) {
57
55
  nodeLog('runner.opencode.profile.cache-hit', {
@@ -93,11 +91,11 @@ export class OpenCodeRunner extends AbstractRunner {
93
91
  this.contextLimitsExpiresAt = expiresAt;
94
92
  this.profileCache = {
95
93
  expiresAt,
96
- discardAt: Date.now() + PROFILE_STALE_TTL_MS,
97
94
  models,
98
95
  contextLimits
99
96
  };
100
97
  this.profileRetryAt = 0;
98
+ this.scheduleProfileRefresh(PROFILE_ENTRY_TTL_MS, capabilities, environment);
101
99
  nodeLog('runner.opencode.profile.providers.completed', {
102
100
  models: models.length,
103
101
  durationMs: Math.round(performance.now() - startedAt)
@@ -108,6 +106,7 @@ export class OpenCodeRunner extends AbstractRunner {
108
106
  if (generation !== this.profileGeneration)
109
107
  return this.profile(capabilities);
110
108
  this.profileRetryAt = Date.now() + PROFILE_FAILURE_RETRY_MS;
109
+ this.scheduleProfileRefresh(PROFILE_FAILURE_RETRY_MS, capabilities, environment);
111
110
  nodeLog('runner.opencode.profile.providers.failed', {
112
111
  durationMs: Math.round(performance.now() - startedAt)
113
112
  });
@@ -153,17 +152,17 @@ export class OpenCodeRunner extends AbstractRunner {
153
152
  };
154
153
  }
155
154
  cachedModels(environment) {
156
- this.pruneProfileCaches(Date.now());
157
155
  void environment;
158
156
  return this.profileCache?.models ?? [];
159
157
  }
160
- pruneProfileCaches(now) {
161
- if (this.profileCache !== undefined && this.profileCache.discardAt <= now)
162
- this.profileCache = undefined;
163
- if (this.profileRetryAt <= now &&
164
- this.profileCache === undefined &&
165
- this.profileRefresh === undefined)
166
- this.profileRetryAt = 0;
158
+ scheduleProfileRefresh(delayMs, capabilities, environment) {
159
+ if (this.profileRefreshTimer !== undefined)
160
+ clearTimeout(this.profileRefreshTimer);
161
+ this.profileRefreshTimer = setTimeout(() => {
162
+ this.profileRefreshTimer = undefined;
163
+ void this.refreshProfile(capabilities, undefined, environment, true);
164
+ }, delayMs);
165
+ this.profileRefreshTimer.unref();
167
166
  }
168
167
  mcpConfiguration(raw, secrets) {
169
168
  const servers = super.mcpConfiguration(raw, secrets);
@@ -331,6 +330,9 @@ export class OpenCodeRunner extends AbstractRunner {
331
330
  this.managedTurns.clear();
332
331
  this.profileCache = undefined;
333
332
  this.profileRefresh = undefined;
333
+ if (this.profileRefreshTimer !== undefined)
334
+ clearTimeout(this.profileRefreshTimer);
335
+ this.profileRefreshTimer = undefined;
334
336
  this.profileRetryAt = 0;
335
337
  this.contextLimits = new Map();
336
338
  this.contextLimitsExpiresAt = 0;
@@ -288,9 +288,12 @@ export class NodeRuntimeState {
288
288
  const value = title.trim().replace(/\s+/g, ' ').slice(0, 160);
289
289
  if (value.length === 0 ||
290
290
  current.customTitle !== null ||
291
- current.runnerTitle === value ||
292
291
  (current.titleSource === 'AUTO' && isDefaultRunnerTitle(value)))
293
292
  return current;
293
+ if (current.runnerTitle === value)
294
+ return current.titleSource === 'RUNNER'
295
+ ? current
296
+ : this.replaceSession(id, { titleSource: 'RUNNER' });
294
297
  return this.replaceSession(id, { runnerTitle: value, titleSource: 'RUNNER' });
295
298
  }
296
299
  createMessageRun(input) {
@@ -316,6 +319,7 @@ export class NodeRuntimeState {
316
319
  recoveryState: 'ACTIVE',
317
320
  version: 0,
318
321
  clientMessageId: input.clientMessageId,
322
+ ...(input.source === undefined ? {} : { source: input.source }),
319
323
  ...(input.initiatedByUserId === undefined
320
324
  ? {}
321
325
  : { initiatedByUserId: input.initiatedByUserId }),
@@ -338,6 +342,7 @@ export class NodeRuntimeState {
338
342
  payload: {
339
343
  clientMessageId: input.clientMessageId,
340
344
  text: input.content,
345
+ ...(input.source === undefined ? {} : { source: input.source }),
341
346
  contexts: [],
342
347
  attachments,
343
348
  delivery: 'ACCEPTED'
@@ -286,6 +286,7 @@ export class ContinuousRunCoordinator {
286
286
  const goalMcp = pending.goal === undefined
287
287
  ? undefined
288
288
  : await this.options.goalMcp?.(pending);
289
+ const scheduledMcp = await this.options.scheduledMcp?.(pending);
289
290
  if (!this.goalCurrent(pending)) {
290
291
  this.dispatchedExecutions.delete(executionId);
291
292
  this.rejectStaleGoal(executionId);
@@ -311,7 +312,11 @@ export class ContinuousRunCoordinator {
311
312
  collaborationMode: pending.collaborationMode,
312
313
  ...(pending.serviceTier === undefined ? {} : { serviceTier: pending.serviceTier }),
313
314
  secretEnvironment: pending.secretEnvironment,
314
- ...(goalMcp === undefined ? {} : { mcpSecretEnvironment: goalMcp.secrets }),
315
+ ...(goalMcp === undefined && scheduledMcp === undefined
316
+ ? {}
317
+ : {
318
+ mcpSecretEnvironment: { ...goalMcp?.secrets, ...scheduledMcp?.secrets }
319
+ }),
315
320
  ...(pending.personalInstructions === undefined
316
321
  ? {}
317
322
  : { personalInstructions: pending.personalInstructions }),
@@ -323,7 +328,8 @@ export class ContinuousRunCoordinator {
323
328
  ...(pending.computerUse === undefined ? {} : { computerUse: pending.computerUse }),
324
329
  mcpInstallations: [
325
330
  ...this.options.mcps(pending.workspaceId),
326
- ...(goalMcp === undefined ? [] : [goalMcp.installation])
331
+ ...(goalMcp === undefined ? [] : [goalMcp.installation]),
332
+ ...(scheduledMcp === undefined ? [] : [scheduledMcp.installation])
327
333
  ]
328
334
  });
329
335
  }
@@ -123,7 +123,7 @@ export class ConversationHistoryService {
123
123
  if (activeCursor !== undefined ||
124
124
  this.options.hasManagedActiveRun(session) ||
125
125
  this.hasManagedRuntimeHistory(session))
126
- return this.readManagedActive(session, input, activeCursor, snapshotSequence, readAt);
126
+ return this.readManagedRuntime(session, input, activeCursor, snapshotSequence, readAt);
127
127
  return this.readHistory(session, input, snapshotSequence, readAt);
128
128
  }
129
129
  async readHistoryPage(session, input) {
@@ -140,7 +140,7 @@ export class ConversationHistoryService {
140
140
  cursor: null
141
141
  }
142
142
  : undefined;
143
- return this.readManagedActive(session, input, olderCursor, this.options.snapshotSequence(), Date.now());
143
+ return this.readManagedRuntime(session, input, olderCursor, this.options.snapshotSequence(), Date.now());
144
144
  }
145
145
  return this.readHistory(session, input, this.options.snapshotSequence(), Date.now());
146
146
  }
@@ -220,7 +220,7 @@ export class ConversationHistoryService {
220
220
  const runtimeTurn = runtimeTurnsByOfficialId.get(turn.id);
221
221
  const hydratedTurn = runtimeTurn === undefined
222
222
  ? turn
223
- : mergeRuntimeUserImageReferences(turn, runtimeTurn, this.options.attachments);
223
+ : mergeRuntimeUserMetadata(turn, runtimeTurn, this.options.attachments);
224
224
  const runtimeSupplementalItems = runtimeSupplementalItemsByOfficialId.get(turn.id) ?? [];
225
225
  const items = [
226
226
  ...hydratedTurn.items.filter((item) => (item.kind !== 'approval_request' && item.kind !== 'user_input_request') ||
@@ -367,6 +367,38 @@ export class ConversationHistoryService {
367
367
  deferredOlderHistory: true
368
368
  });
369
369
  }
370
+ async readManagedRuntime(session, input, cursor, snapshotSequence, readAt) {
371
+ const page = await this.readManagedActive(session, input, cursor, snapshotSequence, readAt);
372
+ if (input.cursor !== undefined ||
373
+ session.externalSessionId === null ||
374
+ this.options.hasManagedActiveRun(session) ||
375
+ !this.hasManagedRuntimeHistory(session) ||
376
+ !page.turns.some((turn) => turn.rewind === undefined &&
377
+ terminalConversationTurn(turn.status) &&
378
+ conversationClientMessageId(turn) !== null))
379
+ return page;
380
+ // A completed managed Turn keeps its live items and Run identity until Runtime cleanup.
381
+ // Only the SDK's matched native Turn may supply a destructive history boundary.
382
+ const official = await this.readOfficial(session, {
383
+ limit: Math.min(500, Math.max(30, page.turns.length + 1))
384
+ }).catch(() => undefined);
385
+ if (official === undefined)
386
+ return page;
387
+ return {
388
+ ...page,
389
+ turns: page.turns.map((turn) => {
390
+ if (turn.rewind !== undefined || !terminalConversationTurn(turn.status))
391
+ return turn;
392
+ const messageId = conversationClientMessageId(turn);
393
+ if (messageId === null)
394
+ return turn;
395
+ const native = official.turns.find((candidate) => candidate.rewind !== undefined &&
396
+ candidate.userItemId !== null &&
397
+ conversationClientMessageId(candidate) === messageId);
398
+ return native?.rewind === undefined ? turn : { ...turn, rewind: native.rewind };
399
+ })
400
+ };
401
+ }
370
402
  async refreshExternalActivity(session) {
371
403
  const runner = this.options.runners.require(session.runner);
372
404
  if (!runner.available(this.options.capabilities())) {
@@ -514,7 +546,7 @@ function latestTurnActivityAt(turn) {
514
546
  function oldestTurnActivityAt(turns) {
515
547
  return turns.length === 0 ? 0 : Math.min(...turns.map(earliestTurnActivityAt));
516
548
  }
517
- function mergeRuntimeUserImageReferences(officialTurn, runtimeTurn, attachments) {
549
+ function mergeRuntimeUserMetadata(officialTurn, runtimeTurn, attachments) {
518
550
  const officialUser = officialTurn.items.find((item) => item.kind === 'user_message');
519
551
  const runtimeUser = runtimeTurn.items.find((item) => item.kind === 'user_message');
520
552
  if (officialUser === undefined ||
@@ -524,14 +556,19 @@ function mergeRuntimeUserImageReferences(officialTurn, runtimeTurn, attachments)
524
556
  return officialTurn;
525
557
  const officialPayload = officialUser.payload;
526
558
  const runtimePayload = runtimeUser.payload;
527
- if (!Array.isArray(officialPayload.attachments) || !Array.isArray(runtimePayload.attachments))
528
- return officialTurn;
529
- const runtimeImages = runtimePayload.attachments.filter((attachment) => isPlainRecord(attachment) &&
559
+ const scheduledSource = runtimePayload.source === 'SCHEDULED' && officialPayload.source !== 'SCHEDULED';
560
+ const officialAttachments = Array.isArray(officialPayload.attachments)
561
+ ? officialPayload.attachments
562
+ : [];
563
+ const runtimeAttachments = Array.isArray(runtimePayload.attachments)
564
+ ? runtimePayload.attachments
565
+ : [];
566
+ const runtimeImages = runtimeAttachments.filter((attachment) => isPlainRecord(attachment) &&
530
567
  typeof attachment.mime === 'string' &&
531
568
  attachment.mime.startsWith('image/'));
532
569
  let runtimeImageIndex = 0;
533
570
  let changed = false;
534
- const mergedAttachments = officialPayload.attachments.map((attachment) => {
571
+ const mergedAttachments = officialAttachments.map((attachment) => {
535
572
  if (!isPlainRecord(attachment) ||
536
573
  typeof attachment.mime !== 'string' ||
537
574
  !attachment.mime.startsWith('image/'))
@@ -562,7 +599,7 @@ function mergeRuntimeUserImageReferences(officialTurn, runtimeTurn, attachments)
562
599
  imageIndex: runtimeAttachment.imageIndex
563
600
  };
564
601
  });
565
- if (!changed)
602
+ if (!changed && !scheduledSource)
566
603
  return officialTurn;
567
604
  return {
568
605
  ...officialTurn,
@@ -571,7 +608,8 @@ function mergeRuntimeUserImageReferences(officialTurn, runtimeTurn, attachments)
571
608
  ...item,
572
609
  payload: {
573
610
  ...officialPayload,
574
- attachments: mergedAttachments
611
+ ...(scheduledSource ? { source: 'SCHEDULED' } : {}),
612
+ ...(changed ? { attachments: mergedAttachments } : {})
575
613
  }
576
614
  }
577
615
  : item)
@@ -583,6 +621,9 @@ function sameConversationTurn(left, right) {
583
621
  const leftMessageId = conversationClientMessageId(left);
584
622
  return leftMessageId !== null && leftMessageId === conversationClientMessageId(right);
585
623
  }
624
+ function terminalConversationTurn(status) {
625
+ return ['SUCCEEDED', 'FAILED', 'CANCELLED', 'INTERRUPTED', 'LOST'].includes(status);
626
+ }
586
627
  function conversationClientMessageId(turn) {
587
628
  for (const item of turn.items) {
588
629
  if (item.kind !== 'user_message' || !isPlainRecord(item.payload))
@@ -25,7 +25,8 @@ export class ManagedRunnerSessionService {
25
25
  if (title === undefined)
26
26
  return;
27
27
  const updated = this.runtime.setRunnerTitleFromNative(session.id, title);
28
- if (updated.runnerTitle !== session.runnerTitle)
28
+ if (updated.runnerTitle !== session.runnerTitle ||
29
+ updated.titleSource !== session.titleSource)
29
30
  this.emitSession(updated);
30
31
  }
31
32
  catch {
@@ -174,7 +174,9 @@ export class NativeSessionProjectionService {
174
174
  titleSource: metadata?.customTitle === null || metadata?.customTitle === undefined
175
175
  ? runnerTitle === null
176
176
  ? 'AUTO'
177
- : 'RUNNER'
177
+ : input.titleOrigin === 'PREVIEW'
178
+ ? 'AUTO'
179
+ : 'RUNNER'
178
180
  : 'CUSTOM',
179
181
  lastActivityAt: timestamp,
180
182
  createdAt: timestamp
@@ -35,6 +35,8 @@ export class NodeControlMessageService {
35
35
  }
36
36
  if (this.options.handleRuntimeCredentialResponse(envelope))
37
37
  return;
38
+ if (this.options.handleScheduledRunAuthorizeResponse?.(envelope) === true)
39
+ return;
38
40
  if (envelope.type.startsWith('direct.') &&
39
41
  this.options.handleDirectTransfer?.(envelope) === true)
40
42
  return;
@@ -72,8 +72,19 @@ export class RunEventService {
72
72
  eventType,
73
73
  payload
74
74
  };
75
- if (eventType !== 'text.delta')
76
- nodeLog('run.event', { runId, eventType, status });
75
+ if (eventType !== 'text.delta') {
76
+ const errorCode = (eventType === 'run.failed' || eventType === 'run.rejected') &&
77
+ isPlainRecord(payload) &&
78
+ typeof payload.code === 'string'
79
+ ? payload.code
80
+ : undefined;
81
+ nodeLog('run.event', {
82
+ runId,
83
+ eventType,
84
+ status,
85
+ ...(errorCode === undefined ? {} : { errorCode })
86
+ });
87
+ }
77
88
  if (status === 'FAILED')
78
89
  this.options.metrics.runnerFailed();
79
90
  let recorded = undefined;
@@ -170,6 +181,7 @@ export class RunEventService {
170
181
  return;
171
182
  }
172
183
  this.options.scheduleContextRefresh(run.sessionId);
184
+ this.options.onTerminalRun?.(run, finalStatus);
173
185
  const idleSnapshot = this.options.refreshWorkspaceSessions(run.workspaceId);
174
186
  this.options.runtime.removeTerminalRun(runId);
175
187
  if (terminalNotification)
@@ -195,6 +207,8 @@ export class RunEventService {
195
207
  const run = this.options.runtime.getRun(runId);
196
208
  if (run === undefined)
197
209
  return undefined;
210
+ if (run.source === 'SCHEDULED' && eventType === 'approval.request')
211
+ return undefined;
198
212
  const session = this.options.runtime.getAgentSession(run.sessionId);
199
213
  if (session === undefined)
200
214
  return undefined;
@@ -209,6 +223,8 @@ export class RunEventService {
209
223
  if (!Number.isSafeInteger(base.userId))
210
224
  return undefined;
211
225
  if (status !== undefined && isTerminalRunStatus(status)) {
226
+ if (run.source === 'SCHEDULED')
227
+ return undefined;
212
228
  if (suppressTerminal)
213
229
  return undefined;
214
230
  return {
@@ -105,11 +105,11 @@ export class RunnerInteractionService {
105
105
  }
106
106
  decideCodexApproval(approvalId, decision) {
107
107
  const codex = this.options.codex();
108
- const requestId = codex.execution.approvals.get(approvalId);
109
- if (requestId === undefined)
108
+ const approval = codex.execution.approvals.get(approvalId);
109
+ if (approval === undefined)
110
110
  return false;
111
111
  try {
112
- codex.respond(requestId, { decision: decision === 'APPROVED' ? 'accept' : 'decline' });
112
+ codex.respond(approval.requestId, { decision: decision === 'APPROVED' ? 'accept' : 'decline' }, approval.runId);
113
113
  }
114
114
  catch {
115
115
  // A disconnected App Server cannot safely receive a late approval.
@@ -148,7 +148,7 @@ export class RunnerInteractionService {
148
148
  async cancelCodexTurn(runId, threadId, turnId, cwd, terminalStatus) {
149
149
  const codex = this.options.codex();
150
150
  try {
151
- await codex.interruptTurn(threadId, turnId);
151
+ await codex.interruptTurn(threadId, turnId, runId);
152
152
  const run = this.options.runtime.getRun(runId);
153
153
  if (run !== undefined && !isTerminalRunStatus(run.status))
154
154
  this.options.emitRun(runId, 'run.completed', { status: terminalStatus }, terminalStatus);
@@ -167,7 +167,7 @@ export class RunnerInteractionService {
167
167
  respondUserInput(runId, requestId, rawAnswers) {
168
168
  if (this.options.runners.respondUserInput(runId, requestId, rawAnswers))
169
169
  return;
170
- if (this.options.codex().execution.userInputs.has(requestId)) {
170
+ if (this.options.codex().execution.userInputs.has(`${runId}\0${requestId}`)) {
171
171
  this.respondCodexUserInput(runId, requestId, rawAnswers);
172
172
  return;
173
173
  }
@@ -209,7 +209,8 @@ export class RunnerInteractionService {
209
209
  }
210
210
  respondCodexUserInput(runId, requestId, rawAnswers) {
211
211
  const codex = this.options.codex();
212
- const pending = codex.execution.userInputs.get(requestId);
212
+ const pendingKey = `${runId}\0${requestId}`;
213
+ const pending = codex.execution.userInputs.get(pendingKey);
213
214
  if (pending === undefined ||
214
215
  pending.runId !== runId ||
215
216
  this.options.runtime.getRun(runId) === undefined)
@@ -219,8 +220,8 @@ export class RunnerInteractionService {
219
220
  answers[question.id] = {
220
221
  answers: this.answers(rawAnswers[question.id], question.allowOther, (value) => question.options.includes(value))
221
222
  };
222
- codex.respond(pending.requestId, { answers });
223
- codex.execution.userInputs.delete(requestId);
223
+ codex.respond(pending.requestId, { answers }, runId);
224
+ codex.execution.userInputs.delete(pendingKey);
224
225
  this.options.emitItem(runId, {
225
226
  itemId: boundedConversationItemId('codex-input', requestId),
226
227
  kind: 'user_input_request',
@@ -0,0 +1,72 @@
1
+ import { createEnvelope, scheduledRunAuthorizeRequestSchema, scheduledRunAuthorizeResponseSchema } from '@myagentroam/protocol';
2
+ export class ScheduledRunAuthorizationControl {
3
+ send;
4
+ #pending = new Map();
5
+ #generation = 0;
6
+ #connected = false;
7
+ #closed = false;
8
+ constructor(send) {
9
+ this.send = send;
10
+ }
11
+ connected() {
12
+ this.disconnected();
13
+ this.#connected = true;
14
+ }
15
+ disconnected() {
16
+ this.#connected = false;
17
+ this.#generation++;
18
+ for (const pending of [...this.#pending.values()])
19
+ pending.reject(new Error('SCHEDULE_SERVER_UNAVAILABLE'));
20
+ }
21
+ close() {
22
+ this.#closed = true;
23
+ this.disconnected();
24
+ }
25
+ handle(envelope) {
26
+ if (envelope.type !== 'schedule.authorize.response')
27
+ return false;
28
+ const pending = envelope.replyTo === undefined ? undefined : this.#pending.get(envelope.replyTo);
29
+ if (pending === undefined || pending.generation !== this.#generation)
30
+ return true;
31
+ const parsed = scheduledRunAuthorizeResponseSchema.safeParse(envelope.payload);
32
+ if (!parsed.success)
33
+ pending.reject(new Error('SCHEDULE_AUTH_RESPONSE_INVALID'));
34
+ else
35
+ pending.resolve(parsed.data);
36
+ return true;
37
+ }
38
+ authorize(request) {
39
+ if (!this.#connected || this.#closed)
40
+ return Promise.reject(new Error('SCHEDULE_SERVER_UNAVAILABLE'));
41
+ const envelope = createEnvelope('schedule.authorize', scheduledRunAuthorizeRequestSchema.parse(request));
42
+ return new Promise((resolve, reject) => {
43
+ const settle = (action) => {
44
+ const pending = this.#pending.get(envelope.id);
45
+ if (pending === undefined)
46
+ return;
47
+ this.#pending.delete(envelope.id);
48
+ clearTimeout(pending.timer);
49
+ action();
50
+ };
51
+ const timer = setTimeout(() => settle(() => reject(new Error('SCHEDULE_AUTH_TIMEOUT'))), 15_000);
52
+ timer.unref();
53
+ this.#pending.set(envelope.id, {
54
+ generation: this.#generation,
55
+ timer,
56
+ resolve: (response) => settle(() => {
57
+ if ('error' in response)
58
+ reject(new Error(response.error.code));
59
+ else
60
+ resolve(response.context);
61
+ }),
62
+ reject: (error) => settle(() => reject(error))
63
+ });
64
+ try {
65
+ this.send(envelope);
66
+ }
67
+ catch {
68
+ settle(() => reject(new Error('SCHEDULE_SERVER_UNAVAILABLE')));
69
+ }
70
+ });
71
+ }
72
+ }
@@ -0,0 +1,95 @@
1
+ export class ScheduledSessionCoordinator {
2
+ options;
3
+ #now;
4
+ #timer;
5
+ #running = false;
6
+ #closed = true;
7
+ constructor(options) {
8
+ this.options = options;
9
+ this.#now = options.now ?? Date.now;
10
+ }
11
+ start() {
12
+ if (!this.#closed)
13
+ return;
14
+ this.#closed = false;
15
+ void this.#tick(true);
16
+ }
17
+ changed() {
18
+ if (!this.#closed)
19
+ this.#arm();
20
+ }
21
+ stop() {
22
+ this.#closed = true;
23
+ if (this.#timer !== undefined)
24
+ clearTimeout(this.#timer);
25
+ this.#timer = undefined;
26
+ }
27
+ #arm() {
28
+ if (this.#timer !== undefined)
29
+ clearTimeout(this.#timer);
30
+ this.#timer = undefined;
31
+ if (this.#running || this.#closed)
32
+ return;
33
+ const next = this.options.store().nextDueAt();
34
+ if (next === null)
35
+ return;
36
+ const delay = Math.max(0, Math.min(next - this.#now(), 2_147_483_647));
37
+ this.#timer = setTimeout(() => void this.#tick(false), delay);
38
+ this.#timer.unref();
39
+ }
40
+ async #tick(initial) {
41
+ if (this.#closed || this.#running)
42
+ return;
43
+ this.#running = true;
44
+ try {
45
+ const now = this.#now();
46
+ for (const task of this.options.store().listDue(now)) {
47
+ if (this.#closed || task.nextRunAt === null)
48
+ break;
49
+ const occurrenceId = this.options
50
+ .store()
51
+ .claim(task.sessionId, task.nextRunAt, task.revision);
52
+ if (occurrenceId === undefined)
53
+ continue;
54
+ let failureCode;
55
+ let submitting = false;
56
+ try {
57
+ if (initial && task.nextRunAt < now)
58
+ failureCode = 'SCHEDULE_NODE_RESTARTED';
59
+ else if (this.options.busy(task))
60
+ failureCode = 'SCHEDULE_SESSION_BUSY';
61
+ if (failureCode === undefined) {
62
+ const context = await this.options.authorize(task, occurrenceId);
63
+ const currentTask = this.options.store().get(task.sessionId);
64
+ if (this.#closed)
65
+ failureCode = 'SCHEDULE_NODE_STOPPED';
66
+ else if (currentTask?.revision !== task.revision || currentTask.enabled !== true)
67
+ failureCode = 'SCHEDULE_TASK_CHANGED';
68
+ else if (this.options.busy(task))
69
+ failureCode = 'SCHEDULE_SESSION_BUSY';
70
+ else {
71
+ submitting = true;
72
+ const submitted = await this.options.submit(task, occurrenceId, context);
73
+ this.options.store().finish(occurrenceId, 'SUBMITTED', undefined, submitted.runId);
74
+ }
75
+ }
76
+ }
77
+ catch (error) {
78
+ failureCode =
79
+ error instanceof Error && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.message)
80
+ ? error.message
81
+ : 'SCHEDULE_EXECUTION_FAILED';
82
+ }
83
+ if (failureCode !== undefined) {
84
+ const status = submitting && failureCode !== 'SCHEDULE_SESSION_BUSY' ? 'FAILED' : 'SKIPPED';
85
+ this.options.store().finish(occurrenceId, status, failureCode);
86
+ this.options.notify(task, occurrenceId, failureCode, status);
87
+ }
88
+ }
89
+ }
90
+ finally {
91
+ this.#running = false;
92
+ this.#arm();
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,72 @@
1
+ export function parseScheduledCron(cron) {
2
+ const fields = cron.trim().split(/\s+/);
3
+ if (fields.length !== 5 || fields[2] !== '*' || fields[3] !== '*')
4
+ throw new Error('SCHEDULE_CRON_INVALID');
5
+ const minute = boundedInteger(fields[0], 0, 59);
6
+ if (fields[1] === '*' && fields[4] === '*')
7
+ return { kind: 'HOURLY', minute };
8
+ const hour = boundedInteger(fields[1], 0, 23);
9
+ if (fields[4] === '*')
10
+ return { kind: 'DAILY', minute, hour };
11
+ return { kind: 'WEEKLY', minute, hour, weekday: boundedInteger(fields[4], 0, 6) };
12
+ }
13
+ export function assertScheduleTimezone(timezone) {
14
+ if (timezone.length < 1 || timezone.length > 128)
15
+ throw new Error('SCHEDULE_TIMEZONE_INVALID');
16
+ try {
17
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
18
+ }
19
+ catch {
20
+ throw new Error('SCHEDULE_TIMEZONE_INVALID');
21
+ }
22
+ }
23
+ export function nextScheduledAt(cron, timezone, after) {
24
+ const cadence = parseScheduledCron(cron);
25
+ assertScheduleTimezone(timezone);
26
+ if (!Number.isSafeInteger(after) || after < 0)
27
+ throw new Error('SCHEDULE_TIME_INVALID');
28
+ const formatter = new Intl.DateTimeFormat('en-US', {
29
+ timeZone: timezone,
30
+ year: 'numeric',
31
+ month: '2-digit',
32
+ day: '2-digit',
33
+ weekday: 'short',
34
+ hour: '2-digit',
35
+ minute: '2-digit',
36
+ hourCycle: 'h23'
37
+ });
38
+ const previousKey = localKey(formatter, after);
39
+ let candidate = Math.floor(after / 60_000) * 60_000 + 60_000;
40
+ for (let index = 0; index < 8 * 24 * 60 + 120; index++, candidate += 60_000) {
41
+ const local = localParts(formatter, candidate);
42
+ if (local.minute === cadence.minute &&
43
+ (cadence.kind === 'HOURLY' || local.hour === cadence.hour) &&
44
+ (cadence.kind !== 'WEEKLY' || local.weekday === cadence.weekday) &&
45
+ local.key !== previousKey)
46
+ return candidate;
47
+ }
48
+ throw new Error('SCHEDULE_NEXT_TIME_UNAVAILABLE');
49
+ }
50
+ function boundedInteger(value, min, max) {
51
+ if (value === undefined || !/^(0|[1-9][0-9]*)$/.test(value))
52
+ throw new Error('SCHEDULE_CRON_INVALID');
53
+ const number = Number(value);
54
+ if (!Number.isInteger(number) || number < min || number > max)
55
+ throw new Error('SCHEDULE_CRON_INVALID');
56
+ return number;
57
+ }
58
+ function localParts(formatter, time) {
59
+ const fields = Object.fromEntries(formatter.formatToParts(time).map((part) => [part.type, part.value]));
60
+ const hour = Number(fields['hour']);
61
+ const minute = Number(fields['minute']);
62
+ const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(fields['weekday'] ?? '');
63
+ return {
64
+ hour,
65
+ minute,
66
+ weekday,
67
+ key: `${fields['year']}-${fields['month']}-${fields['day']}T${fields['hour']}:${fields['minute']}`
68
+ };
69
+ }
70
+ function localKey(formatter, time) {
71
+ return localParts(formatter, time).key;
72
+ }