@atolis-hq/wake 0.3.51 → 0.3.53

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.
@@ -12,6 +12,7 @@ import { loadFakeScenarios } from './fake-scenarios.js';
12
12
  import { composeIntegrationRuntime } from './integration-runtime.js';
13
13
  import { resolveWakePaths } from './paths.js';
14
14
  import { composePersistence } from './persistence-composition.js';
15
+ import { createFileProjectionRunSerialiser, } from './projection-runtime.js';
15
16
  import { createRunnerQuotaReporter } from './runner-quota-reporter.js';
16
17
  import { createRunnerRegistry } from './runner-registry.js';
17
18
  import { FileScheduleCheckpointStore } from './schedule-checkpoint-store.js';
@@ -130,6 +131,7 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
130
131
  work,
131
132
  ids,
132
133
  wakeRoot,
134
+ projectionRunSerialiser: createFileProjectionRunSerialiser(paths.dataRoot),
133
135
  scheduleCheckpoints: options.scheduleCheckpoints ?? new FileScheduleCheckpointStore(paths.dataRoot),
134
136
  ...(options.decorateDeliveryAdapter === undefined
135
137
  ? {}
@@ -153,6 +155,7 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
153
155
  lookup,
154
156
  orchestration,
155
157
  execution,
158
+ recovery,
156
159
  ...(transcriptStore === undefined ? {} : { transcriptStore }),
157
160
  runnerControls,
158
161
  controlPlane,
@@ -55,7 +55,7 @@ export async function composeIntegrationRuntime(input) {
55
55
  // pipeline's own catchUpProjections, the API's manual tick, and the
56
56
  // resident's standalone projection pump) shares this one instance, so
57
57
  // serializing it here in-process covers all of them without a file lock.
58
- const projectionRunner = serializeRunRegisteredOnce(createRuntimeProjectionRunner(input.journal, input.projections, input.checkpoints));
58
+ const projectionRunner = serializeRunRegisteredOnce(createRuntimeProjectionRunner(input.journal, input.projections, input.checkpoints, input.projectionRunSerialiser));
59
59
  const delivery = new DeliveryService({
60
60
  journal: input.journal,
61
61
  intents: async () => (await input.projections.list(IntegrationStreamKind.Delivery)).map(({ value }) => value),
@@ -1,9 +1,10 @@
1
+ import { join } from 'node:path';
1
2
  import { activityProjectionDefinitions } from '../activities/index.js';
2
3
  import { controlPlaneProjectionDefinitions } from '../control-plane/index.js';
3
4
  import { executionProjection, runsByWorkflowInstanceProjection } from '../execution/index.js';
4
5
  import { deliveryProjectionDefinitions } from '../integrations/index.js';
5
6
  import { orchestrationProjection, workflowDefinitionsProjection, workflowsByWorkItemProjection, } from '../orchestration/index.js';
6
- import { ProjectionRunner } from '../persistence/index.js';
7
+ import { acquireFileLock, ProjectionRunner, } from '../persistence/index.js';
7
8
  import { resourceCorrelationProjection, resourceProjection, resourcesByExternalKeyProjection, workCorrelationsProjection, } from '../resources/index.js';
8
9
  import { workProjection } from '../work/index.js';
9
10
  import { analyticsProjection } from './analytics-projection.js';
@@ -25,6 +26,26 @@ export const runtimeProjectionDefinitions = [
25
26
  boardProjection,
26
27
  analyticsProjection,
27
28
  ];
28
- export function createRuntimeProjectionRunner(journal, projections, checkpoints) {
29
- return new ProjectionRunner(journal, projections, checkpoints, runtimeProjectionDefinitions);
29
+ export function createRuntimeProjectionRunner(journal, projections, checkpoints, serialiseRun) {
30
+ return new ProjectionRunner(journal, projections, checkpoints, runtimeProjectionDefinitions, serialiseRun);
31
+ }
32
+ export function createFileProjectionRunSerialiser(dataRoot) {
33
+ const path = join(dataRoot, 'locks', 'projection-runner.lock');
34
+ return async (operation) => {
35
+ while (true) {
36
+ const lock = await acquireFileLock(path, {
37
+ staleAfterMs: 60_000,
38
+ staleRequiresDeadProcess: true,
39
+ });
40
+ if (lock.acquired) {
41
+ try {
42
+ return await operation();
43
+ }
44
+ finally {
45
+ await lock.release();
46
+ }
47
+ }
48
+ await new Promise((resolve) => setTimeout(resolve, 10));
49
+ }
50
+ };
30
51
  }
@@ -1,4 +1,6 @@
1
1
  import { ControlStreamKind, ineligibleRunners, } from '../control-plane/index.js';
2
+ import { ExecutionFailureCode, RunStatus } from '../execution/index.js';
3
+ import { correlationId, EventActorKind } from '../kernel/index.js';
2
4
  import { ApiCommandStatus, presentRun } from '../surfaces/index.js';
3
5
  import { projectionMeta, sampledMeta } from './surface-api-metadata.js';
4
6
  import { projectionPage } from './surface-api-projection-pages.js';
@@ -6,6 +8,15 @@ import { withWorkflowContext } from './surface-api-run-context.js';
6
8
  import { readWorkTranscript } from './surface-api-transcripts.js';
7
9
  export function createExecutionApplications(root, now) {
8
10
  return {
11
+ async resolveAmbiguousRun(runId, command) {
12
+ const context = resolutionContext(runId, command.idempotencyKey, now);
13
+ const run = await root.recovery.resolve(runId, {
14
+ kind: RunStatus.Failed,
15
+ failure: { kind: ExecutionFailureCode.Unexpected, message: command.message },
16
+ }, context);
17
+ await root.orchestration.resolveExecutionFailure(run.workflowInstanceId, { activationId: run.activationId, runId: run.runId, reason: command.message }, context);
18
+ return commandAccepted(command, now());
19
+ },
9
20
  async pauseRunner(runnerId, command) {
10
21
  await root.runnerControls.pause(runnerId, command.idempotencyKey);
11
22
  return commandAccepted(command, now());
@@ -85,6 +96,15 @@ export function createExecutionApplications(root, now) {
85
96
  },
86
97
  };
87
98
  }
99
+ function resolutionContext(runId, idempotencyKey, now) {
100
+ const commandId = `run:${runId}:resolve-failed:${idempotencyKey}`;
101
+ return {
102
+ commandId,
103
+ correlationId: correlationId(commandId),
104
+ occurredAt: now(),
105
+ actor: { kind: EventActorKind.Operator, id: 'web' },
106
+ };
107
+ }
88
108
  function commandAccepted(command, acceptedAt) {
89
109
  return {
90
110
  commandId: `runner:${command.idempotencyKey}`,
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "gc813fba";
111
+ export const wakeVersion = "gfcfd038";
@@ -46,8 +46,13 @@ export class RecoveryService {
46
46
  const loaded = await this.repository.load(currentRunId);
47
47
  if (loaded.view === null)
48
48
  throw new Error(`Run ${id} does not exist`);
49
- if (!loaded.view.escalated || loaded.view.status !== RunStatus.Ambiguous)
49
+ if (!loaded.view.escalated || loaded.view.status !== RunStatus.Ambiguous) {
50
+ if (loaded.events?.some((event) => event.causationId === context.commandId &&
51
+ (event.eventType === ExecutionEventType.RunSucceeded ||
52
+ event.eventType === ExecutionEventType.RunFailed)))
53
+ return loaded.view;
50
54
  throw new Error(`Run ${id} is not escalated`);
55
+ }
51
56
  const draft = resolution.kind === RunStatus.Succeeded
52
57
  ? createRunExecutionEventDraft({
53
58
  ...resolutionMetadata(currentRunId, loaded.view, context),
@@ -9,7 +9,8 @@ export class RunRepository {
9
9
  }
10
10
  async load(runId) {
11
11
  const events = await this.journal.readStream(runStream(runId));
12
- return { sequence: events.length, view: foldRun(events.map(decodeRunExecutionEvent)) };
12
+ const decoded = events.map(decodeRunExecutionEvent);
13
+ return { sequence: events.length, events: decoded, view: foldRun(decoded) };
13
14
  }
14
15
  async append(runId, sequence, drafts) {
15
16
  const events = await this.journal.append(runStream(runId), sequence, drafts);
@@ -80,7 +80,8 @@ export class AdvanceWorkflow {
80
80
  async resolveExecutionFailure(id, input, context) {
81
81
  const loaded = await this.repository.load(id);
82
82
  if (loaded.view === null ||
83
- loaded.view.status === WorkflowStatus.Blocked ||
83
+ (loaded.view.status === WorkflowStatus.Blocked &&
84
+ !isAmbiguityResolutionBlock(loaded.view.blockReason)) ||
84
85
  loaded.view.pendingActivation?.activationId !== input.activationId ||
85
86
  loaded.view.acceptedOutcomes.includes(input.activationId))
86
87
  return loaded.view;
@@ -203,3 +204,6 @@ export class AdvanceWorkflow {
203
204
  return matchWatches(await this.listAllLoaded(), event, this.workflows, context);
204
205
  }
205
206
  }
207
+ function isAmbiguityResolutionBlock(reason) {
208
+ return reason !== undefined && /^run-ambiguous-after-\d+-attempts$/.test(reason);
209
+ }
@@ -1,14 +1,19 @@
1
+ async function runImmediately(operation) {
2
+ return operation();
3
+ }
1
4
  export class ProjectionRunner {
2
5
  journal;
3
6
  projections;
4
7
  checkpoints;
5
8
  registered;
9
+ serialiseRun;
6
10
  caughtUpToGlobalPosition;
7
- constructor(journal, projections, checkpoints, registered = []) {
11
+ constructor(journal, projections, checkpoints, registered = [], serialiseRun = runImmediately) {
8
12
  this.journal = journal;
9
13
  this.projections = projections;
10
14
  this.checkpoints = checkpoints;
11
15
  this.registered = registered;
16
+ this.serialiseRun = serialiseRun;
12
17
  }
13
18
  // One shared journal read for every registered definition, not one per
14
19
  // definition: readAll ultimately re-derives its content-fingerprint via
@@ -26,6 +31,9 @@ export class ProjectionRunner {
26
31
  // batch — otherwise a backlog bigger than `limit` would get marked caught
27
32
  // up after its first (partial) batch and never finish draining.
28
33
  async runRegisteredOnce(limit = 100) {
34
+ return this.serialiseRun(() => this.runRegisteredOnceUnlocked(limit));
35
+ }
36
+ async runRegisteredOnceUnlocked(limit) {
29
37
  const allEvents = await this.journal.readAll(0);
30
38
  const latestGlobalPosition = allEvents.at(-1)?.globalPosition ?? 0;
31
39
  if (this.caughtUpToGlobalPosition !== undefined &&
@@ -37,6 +45,9 @@ export class ProjectionRunner {
37
45
  return counts.reduce((total, count) => total + count, 0);
38
46
  }
39
47
  async runOnce(definition, limit = 100) {
48
+ return this.serialiseRun(() => this.runOnceUnlocked(definition, limit));
49
+ }
50
+ async runOnceUnlocked(definition, limit) {
40
51
  const consumer = `projection:${definition.name}`;
41
52
  const events = await this.journal.readAll(await this.checkpoints.load(consumer), limit);
42
53
  return this.apply(definition, consumer, events);
@@ -66,11 +77,14 @@ export class ProjectionRunner {
66
77
  return events.length;
67
78
  }
68
79
  async rebuild(definition) {
80
+ return this.serialiseRun(() => this.rebuildUnlocked(definition));
81
+ }
82
+ async rebuildUnlocked(definition) {
69
83
  await this.projections.clear(definition.name);
70
84
  await this.checkpoints.reset(`projection:${definition.name}`);
71
85
  let total = 0;
72
86
  while (true) {
73
- const count = await this.runOnce(definition);
87
+ const count = await this.runOnceUnlocked(definition, 100);
74
88
  total += count;
75
89
  if (count < 100)
76
90
  return total;
@@ -3,6 +3,9 @@ import { accepted, ApiPathError, decodePathSegment, invalidPath, invalidQuery, i
3
3
  export async function dispatchCommand(applications, url, body) {
4
4
  if (url.search !== '')
5
5
  return invalidQuery('Command routes do not accept query parameters');
6
+ const ambiguityResolution = await dispatchAmbiguousRunResolution(applications, url.pathname, body);
7
+ if (ambiguityResolution !== undefined)
8
+ return ambiguityResolution;
6
9
  const request = commandRequest(body);
7
10
  if ('status' in request)
8
11
  return request;
@@ -15,6 +18,21 @@ export async function dispatchCommand(applications, url, body) {
15
18
  const runner = await dispatchRunnerCommand(applications, url.pathname, request);
16
19
  return runner ?? problem(404, 'Not Found', `No command route for ${url.pathname}`);
17
20
  }
21
+ async function dispatchAmbiguousRunResolution(applications, pathname, body) {
22
+ const match = /^\/api\/v1\/runs\/([^/]+)\/commands\/resolve-failed$/.exec(pathname);
23
+ if (match === null)
24
+ return undefined;
25
+ const runId = decodePathSegment(match[1]);
26
+ if (runId instanceof ApiPathError)
27
+ return invalidPath(runId.message);
28
+ const request = ambiguityFailureResolutionRequest(body);
29
+ if ('status' in request)
30
+ return request;
31
+ const operation = applications.execution.resolveAmbiguousRun;
32
+ if (operation === undefined)
33
+ return unavailable('resolve-ambiguous-run', await applications.execution.get?.(runId));
34
+ return accepted(await operation(runId, request), applications.now());
35
+ }
18
36
  async function dispatchWorkCommand(applications, pathname, request) {
19
37
  const match = /^\/api\/v1\/work-items\/([^/]+)\/commands\/(freeze|unfreeze|delete|retry)$/.exec(pathname);
20
38
  if (match === null)
@@ -80,3 +98,20 @@ function commandRequest(body) {
80
98
  ? { idempotencyKey: value }
81
99
  : invalidRequest('idempotencyKey', 'Must be at most 200 characters');
82
100
  }
101
+ function ambiguityFailureResolutionRequest(body) {
102
+ if (!isObject(body))
103
+ return invalidRequest('idempotencyKey', 'A JSON object with an idempotency key is required');
104
+ if (Object.keys(body).some((key) => key !== 'idempotencyKey' && key !== 'message'))
105
+ return invalidRequest('', 'The command body contains unknown fields');
106
+ const idempotencyKey = body.idempotencyKey;
107
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '')
108
+ return invalidRequest('idempotencyKey', 'Must be a non-empty string');
109
+ if (idempotencyKey.length > 200)
110
+ return invalidRequest('idempotencyKey', 'Must be at most 200 characters');
111
+ const message = body.message;
112
+ if (typeof message !== 'string' || message.trim() === '')
113
+ return invalidRequest('message', 'Must be a non-empty string');
114
+ if (message.length > 2_000)
115
+ return invalidRequest('message', 'Must be at most 2000 characters');
116
+ return { idempotencyKey, message };
117
+ }
@@ -2,6 +2,7 @@ export const executionRoutes = [
2
2
  '/api/v1/runs',
3
3
  '/api/v1/runs/:runId',
4
4
  '/api/v1/runs/:runId/transcript',
5
+ '/api/v1/runs/:runId/commands/resolve-failed',
5
6
  '/api/v1/runners',
6
7
  '/api/v1/runners/:runnerId/commands/pause',
7
8
  '/api/v1/runners/:runnerId/commands/unpause',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.51",
3
+ "version": "0.3.53",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {