@atolis-hq/wake 0.4.43 → 0.4.45

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 (25) hide show
  1. package/dist/src/bootstrap/persistence-composition.js +1 -5
  2. package/dist/src/bootstrap/self-update-application.d.ts +2 -0
  3. package/dist/src/bootstrap/self-update-application.js +53 -6
  4. package/dist/src/bootstrap/surface-cli-applications.js +16 -14
  5. package/dist/src/bootstrap/version.js +1 -1
  6. package/dist/src/control-plane/application/activation-scheduler-ports.d.ts +2 -0
  7. package/dist/src/control-plane/application/activation-scheduler.js +59 -34
  8. package/dist/src/control-plane/application/advance-once-dispatch.d.ts +3 -0
  9. package/dist/src/control-plane/application/advance-once-dispatch.js +69 -39
  10. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/README.md +11 -15
  11. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/file-processor-state-store.d.ts +2 -2
  12. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/file-processor-state-store.d.ts.map +1 -1
  13. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/file-processor-state-store.js +15 -58
  14. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/file-projection-store.d.ts +1 -6
  15. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/file-projection-store.d.ts.map +1 -1
  16. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/file-projection-store.js +20 -68
  17. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/index.d.ts +1 -1
  18. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/index.d.ts.map +1 -1
  19. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/processor-state-paths.d.ts +0 -3
  20. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/processor-state-paths.d.ts.map +1 -1
  21. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/processor-state-paths.js +5 -20
  22. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/storage-name.d.ts +1 -1
  23. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/storage-name.d.ts.map +1 -1
  24. package/dist/src/node_modules/@atolis-hq/eventing-filesystem/dist/storage-name.js +5 -7
  25. package/package.json +1 -1
@@ -1,5 +1,4 @@
1
1
  import { FileCheckpointStore, FileEventJournal, FileProcessorStateStore, FileProjectionStore, createFileProcessorRunSerialiser, } from '@atolis-hq/eventing-filesystem';
2
- import { DeliveryOutcomeProcessorConsumer } from '../integrations/index.js';
3
2
  function identity(value) {
4
3
  return value;
5
4
  }
@@ -27,10 +26,7 @@ export function composePersistence(paths, clock, options) {
27
26
  const subscriptionRunSerialiser = options.subscriptionRunSerialiser ?? createFileProcessorRunSerialiser(paths.dataRoot);
28
27
  return {
29
28
  journal: serializeJournalAppends((options.decorateJournal ?? identity)(options.journal ?? new FileEventJournal(paths.dataRoot, clock))),
30
- projections: (options.decorateProjections ?? identity)(options.projections ??
31
- new FileProjectionStore(paths.dataRoot, {
32
- protectedProcessorStateConsumers: [DeliveryOutcomeProcessorConsumer],
33
- })),
29
+ projections: (options.decorateProjections ?? identity)(options.projections ?? new FileProjectionStore(paths.dataRoot)),
34
30
  checkpoints: (options.decorateCheckpoints ?? identity)(options.checkpoints ?? new FileCheckpointStore(paths.dataRoot)),
35
31
  processorState: options.processorState ?? new FileProcessorStateStore(paths.dataRoot),
36
32
  subscriptionRunSerialiser,
@@ -16,6 +16,7 @@ export interface SourceUpdatePort {
16
16
  * non-sandbox self-updates.
17
17
  */
18
18
  export interface SelfUpdateRolloutPort {
19
+ prepare(tag: string): Promise<void>;
19
20
  deploy(tag: string): Promise<void>;
20
21
  rollback(tag: string): Promise<void>;
21
22
  recordFailure(tag: string, error: unknown): Promise<void>;
@@ -27,6 +28,7 @@ export interface SelfUpdateRolloutPort {
27
28
  * durably record every supplied Run's maintenance request.
28
29
  */
29
30
  export interface SelfUpdateQuiescePort {
31
+ read?(): Promise<UpdateMaintenanceState | null>;
30
32
  acquire(tag: string, retryFailed?: boolean): Promise<UpdateMaintenanceState>;
31
33
  exclusive?<Value>(tag: string, retryFailed: boolean, operation: (state: UpdateMaintenanceState) => Promise<Value>): Promise<Value>;
32
34
  activeRuns(): Promise<readonly {
@@ -59,6 +59,11 @@ async function currentTag(input) {
59
59
  return (await input.source.currentTag?.()) ?? (await input.ledger.read());
60
60
  }
61
61
  async function startUpdateAttempt(input, tag, force) {
62
+ const existing = await input.quiesce?.read?.();
63
+ if (existing === null ||
64
+ existing === undefined ||
65
+ existing.phase === UpdateMaintenancePhase.Failed)
66
+ await prepareCandidate(input, tag);
62
67
  if (input.quiesce?.exclusive !== undefined)
63
68
  return input.quiesce.exclusive(tag, force, (lease) => updateAttempt(input, tag, force, lease));
64
69
  return updateAttempt(input, tag, force, await input.quiesce?.acquire(tag, force));
@@ -66,24 +71,30 @@ async function startUpdateAttempt(input, tag, force) {
66
71
  async function updateAttempt(input, tag, force, lease) {
67
72
  const maintenanceTag = lease?.tag ?? tag;
68
73
  const attemptId = lease?.attemptId;
69
- const context = {};
74
+ const context = {
75
+ activationStarted: false,
76
+ rollbackSucceeded: false,
77
+ recoveryRequired: false,
78
+ recoveryVerified: false,
79
+ };
70
80
  try {
71
81
  return await performUpdateAttempt(input, tag, force, lease, context);
72
82
  }
73
83
  catch (error) {
74
- await recordUpdateFailure(input, maintenanceTag, attemptId, context.deployError ?? error, error);
84
+ await recordUpdateFailure(input, maintenanceTag, attemptId, context.deployError ?? error, error, context);
75
85
  throw error;
76
86
  }
77
87
  }
78
88
  async function performUpdateAttempt(input, tag, force, lease, context) {
79
89
  if (lease !== undefined && requiresMaintenanceRecovery(lease)) {
90
+ context.recoveryRequired = true;
80
91
  await resumePendingMaintenance(input, lease);
81
92
  return false;
82
93
  }
83
94
  if (!isStartableMaintenanceLease(lease, tag))
84
95
  return false;
85
96
  await quiesceForUpdate(input);
86
- await recoverPendingUpdate(input.ledger, input.source);
97
+ await recoverPendingUpdate(input.ledger, input.source, context);
87
98
  if (!force && (await input.ledger.read()) === tag) {
88
99
  await input.quiesce?.clear(lease?.attemptId);
89
100
  return false;
@@ -116,13 +127,18 @@ async function runForwardUpdate(input, tag, attemptId, context) {
116
127
  readLedger: input.ledger.read,
117
128
  writeLedger: input.ledger.write,
118
129
  update: async (nextTag) => {
119
- await input.source.checkout(nextTag);
130
+ if (input.rollout === undefined)
131
+ await input.source.checkout(nextTag);
132
+ context.activationStarted = true;
120
133
  context.deployError = await deployRollout(input.rollout, nextTag);
121
134
  },
122
135
  health: async () => context.deployError === undefined && input.source.healthy(),
123
136
  rollback: async (priorTag) => {
124
137
  await rollbackUpdate(input, priorTag, attemptId);
138
+ if (!(await input.source.healthy()))
139
+ throw new Error(`Self-update rollback could not verify the prior tag ${priorTag}`);
125
140
  rollbackSucceeded = true;
141
+ context.rollbackSucceeded = true;
126
142
  },
127
143
  });
128
144
  }
@@ -162,7 +178,18 @@ async function rollbackUpdate(input, priorTag, attemptId) {
162
178
  await input.source.checkout(priorTag);
163
179
  await input.rollout?.rollback(priorTag);
164
180
  }
165
- async function recordUpdateFailure(input, tag, attemptId, rolloutError, originalError) {
181
+ async function recordUpdateFailure(input, tag, attemptId, rolloutError, originalError, context) {
182
+ if (context.rollbackSucceeded ||
183
+ (!context.activationStarted && (!context.recoveryRequired || context.recoveryVerified))) {
184
+ try {
185
+ await recordRolloutFailure(input.rollout, tag, rolloutError);
186
+ await input.ledger.recordBad(tag);
187
+ }
188
+ finally {
189
+ await input.quiesce?.clear(attemptId);
190
+ }
191
+ return;
192
+ }
166
193
  try {
167
194
  await input.quiesce?.fail(originalError, attemptId);
168
195
  }
@@ -184,6 +211,24 @@ async function recordRolloutFailure(rollout, tag, error) {
184
211
  // Best-effort: a failure-log write must never mask the original update failure.
185
212
  }
186
213
  }
214
+ async function prepareCandidate(input, tag) {
215
+ if (!(await input.source.isClean()))
216
+ throw new Error('Self-update requires a clean source checkout');
217
+ if (input.rollout === undefined)
218
+ return;
219
+ const priorTag = await input.ledger.read();
220
+ try {
221
+ await input.source.checkout(tag);
222
+ await input.rollout.prepare(tag);
223
+ }
224
+ catch (error) {
225
+ if (priorTag !== null)
226
+ await input.source.checkout(priorTag);
227
+ await recordRolloutFailure(input.rollout, tag, error);
228
+ await input.ledger.recordBad(tag);
229
+ throw new UpdateCandidateFailure(error);
230
+ }
231
+ }
187
232
  /**
188
233
  * A restart must never repeat a forward deploy. The persisted phase tells us
189
234
  * that source/rollout work may already have begun, so recover the prior
@@ -210,11 +255,13 @@ async function quiesceActiveRuns(quiesce) {
210
255
  function formatError(error) {
211
256
  return error instanceof Error ? error.message : String(error);
212
257
  }
213
- async function recoverPendingUpdate(ledger, source) {
258
+ async function recoverPendingUpdate(ledger, source, context) {
214
259
  const priorTag = await ledger.recover();
215
260
  if (priorTag === null)
216
261
  return;
262
+ context.recoveryRequired = true;
217
263
  await source.checkout(priorTag);
218
264
  if (!(await source.healthy()))
219
265
  throw new Error(`Self-update recovery could not verify the prior tag ${priorTag}`);
266
+ context.recoveryVerified = true;
220
267
  }
@@ -400,6 +400,7 @@ async function activeExecutionRunIds(root) {
400
400
  /** The production maintenance boundary shared by the CLI update application. */
401
401
  export function createSelfUpdateQuiescePort(root) {
402
402
  return {
403
+ read: () => root.maintenance.read(),
403
404
  acquire: (tag, retryFailed) => root.maintenance.acquire(tag, retryFailed),
404
405
  exclusive: (tag, retryFailed, operation) => root.maintenance.runExclusive(tag, retryFailed, operation),
405
406
  activeRuns: () => activeExecutionRuns(root),
@@ -433,18 +434,20 @@ function createSandboxRuntimeApplications(root) {
433
434
  };
434
435
  }
435
436
  /**
436
- * Builds a running self-update on the sandbox container onto a specific
437
- * image tag: rebuild the image, swap the container onto it, and confirm the
438
- * resident `wake start` process actually came back up. Reused for both the
439
- * forward deploy and a same-shaped rollback to a previously-built tag.
437
+ * Builds a candidate image before the maintenance window. A failed build
438
+ * cannot affect the running sandbox.
440
439
  */
441
- async function deploySandboxTag(root, docker, tag, image, overrides) {
440
+ async function buildSandboxTag(root, docker, image, overrides) {
442
441
  const port = createSandboxDockerPort(docker, sandboxDockerOptions(root, { image, ...overrides }));
443
- const wakeInvocation = overrides?.development?.mode === 'source'
442
+ await port.build();
443
+ }
444
+ /** Swaps the already-built candidate image and verifies the resident host. */
445
+ async function deploySandboxTag(root, docker, tag, image, development) {
446
+ const port = createSandboxDockerPort(docker, sandboxDockerOptions(root, { image, development }));
447
+ const wakeInvocation = development?.mode === 'source'
444
448
  ? ['node', '/app/dist/src/main.js']
445
449
  : sandboxWakeInvocation(root);
446
450
  const healthcheckRoot = `/tmp/wake-self-update-healthcheck-${tag}`;
447
- await port.build();
448
451
  try {
449
452
  await port.update();
450
453
  }
@@ -541,15 +544,14 @@ function createOperationalApplications(root) {
541
544
  ledger: createUpdateLedger(root.paths.wakeRoot),
542
545
  source: updatePort,
543
546
  rollout: {
544
- async deploy(deployTag) {
545
- await waitForActiveRuns({
546
- activeRunIds: () => activeExecutionRunIds(root),
547
- sleep: async (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
548
- });
549
- await deploySandboxTag(root, dockerCli, deployTag, `${imageRepository}:${deployTag}`, {
547
+ async prepare(prepareTag) {
548
+ await buildSandboxTag(root, dockerCli, `${imageRepository}:${prepareTag}`, {
550
549
  development: updateDevelopment,
551
- buildVersion: deployTag,
550
+ buildVersion: prepareTag,
552
551
  });
552
+ },
553
+ async deploy(deployTag) {
554
+ await deploySandboxTag(root, dockerCli, deployTag, `${imageRepository}:${deployTag}`, updateDevelopment);
553
555
  // A later deploy succeeding after a prior rollback clears the stale warning.
554
556
  await failureLog.clear();
555
557
  },
@@ -124,6 +124,6 @@ export function resolveWakeVersion(options = {}) {
124
124
  return `g${headHash.slice(0, 7)}`;
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "v0.4.43";
127
+ export const wakeVersion = "v0.4.45";
128
128
  /** The registry-installable version used by packaged sandbox images. */
129
129
  export const wakePackageVersion = packageVersion(defaultRepoRoot) ?? wakeVersion;
@@ -12,6 +12,7 @@ export interface OrchestrationPort {
12
12
  activation: ActivityActivationView;
13
13
  }[]>;
14
14
  listWaiting(): Promise<readonly (WorkflowInstanceView | null)[]>;
15
+ get?(workflowInstanceId: string): Promise<WorkflowInstanceView | null>;
15
16
  expireTimedOutWaits?(context: CommandContext): Promise<readonly WorkflowInstanceView[]>;
16
17
  listAll?(): Promise<readonly WorkflowInstanceView[]>;
17
18
  validateActivationDispatch?(workflowInstanceId: string, context: CommandContext): Promise<boolean>;
@@ -32,6 +33,7 @@ export interface OrchestrationPort {
32
33
  readonly message: string;
33
34
  }, context: CommandContext): Promise<WorkflowInstanceView | null>;
34
35
  markActivationStarted(workflowInstanceId: string, activationId: string, context: CommandContext): Promise<WorkflowInstanceView | null>;
36
+ block?(workflowInstanceId: string, reason: string, context: CommandContext): Promise<WorkflowInstanceView | null>;
35
37
  }
36
38
  export interface ExecutionPort {
37
39
  recoverActive?(owner: string): Promise<readonly RunView[]>;
@@ -5,7 +5,7 @@ import { isAmbiguityResolutionBlock, WorkflowStatus, } from '../../orchestration
5
5
  import { WorkStatus } from '../../work/index.js';
6
6
  import { ControlStreamKind } from '../contracts/streams.js';
7
7
  import { DispatchPolicy } from '../domain/dispatch-policy.js';
8
- import { isRunnerQuotaOutcome, reportUnappliedRunnerQuotaRetry, runDispatchLoop, runnerQuotaMessage, } from './advance-once-dispatch.js';
8
+ import { advancementWasDurablyApplied, blockIsolatedWorkflow, isRunnerQuotaOutcome, reportUnappliedRunnerQuotaRetry, runDispatchLoop, runnerQuotaMessage, } from './advance-once-dispatch.js';
9
9
  import { findUnresolvedSucceededTerminal, findUnresolvedTerminal, } from './execution-reconciliation.js';
10
10
  export function createActivationScheduler(orchestration, execution, resources, clock, dependencies) {
11
11
  const runnerIneligibility = dependencies.runnerIneligibility ?? (async () => new Set());
@@ -106,43 +106,68 @@ export function createActivationScheduler(orchestration, execution, resources, c
106
106
  if (recovery !== undefined) {
107
107
  if (await isDispatchPaused())
108
108
  return { kind: 'paused' };
109
- if (recovery.run.status === RunStatus.Succeeded) {
110
- if (isRunnerQuotaOutcome(recovery.run.outcome)) {
111
- const retried = await orchestration.retryRunnerQuotaFailure?.(recovery.item.workflow.workflowInstanceId, {
112
- activationId: recovery.item.activation.activationId,
113
- runId: recovery.run.runId,
114
- runnerName: recovery.run.runner?.name ?? 'unknown-runner',
115
- message: runnerQuotaMessage(recovery.run.outcome),
116
- }, context(recovery.run.runId));
117
- reportUnappliedRunnerQuotaRetry(retried, {
118
- activationId: recovery.item.activation.activationId,
119
- runId: recovery.run.runId,
120
- });
121
- }
122
- else {
123
- await orchestration.acceptOutcome({
124
- workflowInstanceId: recovery.item.workflow.workflowInstanceId,
125
- activationId: recovery.item.activation.activationId,
126
- outcome: recovery.run.outcome,
127
- }, context(recovery.run.runId));
109
+ try {
110
+ if (recovery.run.status === RunStatus.Succeeded) {
111
+ if (isRunnerQuotaOutcome(recovery.run.outcome)) {
112
+ const retried = await orchestration.retryRunnerQuotaFailure?.(recovery.item.workflow.workflowInstanceId, {
113
+ activationId: recovery.item.activation.activationId,
114
+ runId: recovery.run.runId,
115
+ runnerName: recovery.run.runner?.name ?? 'unknown-runner',
116
+ message: runnerQuotaMessage(recovery.run.outcome),
117
+ }, context(recovery.run.runId));
118
+ reportUnappliedRunnerQuotaRetry(retried, {
119
+ activationId: recovery.item.activation.activationId,
120
+ runId: recovery.run.runId,
121
+ });
122
+ }
123
+ else {
124
+ await orchestration.acceptOutcome({
125
+ workflowInstanceId: recovery.item.workflow.workflowInstanceId,
126
+ activationId: recovery.item.activation.activationId,
127
+ outcome: recovery.run.outcome,
128
+ }, context(recovery.run.runId));
129
+ }
130
+ return {
131
+ kind: 'progressed',
132
+ dispatched: [
133
+ { activationId: recovery.item.activation.activationId, runId: recovery.run.runId },
134
+ ],
135
+ };
128
136
  }
137
+ await orchestration.resolveExecutionFailure?.(recovery.item.workflow.workflowInstanceId, {
138
+ activationId: recovery.item.activation.activationId,
139
+ runId: recovery.run.runId,
140
+ reason: recovery.run.failure?.message ?? 'execution failed',
141
+ }, context(recovery.run.runId));
129
142
  return {
130
- kind: 'progressed',
131
- dispatched: [
132
- { activationId: recovery.item.activation.activationId, runId: recovery.run.runId },
133
- ],
143
+ kind: WorkflowStatus.Blocked,
144
+ workflowInstanceId: recovery.item.workflow.workflowInstanceId,
145
+ reason: recovery.run.failure?.message ?? 'execution failed',
134
146
  };
135
147
  }
136
- await orchestration.resolveExecutionFailure?.(recovery.item.workflow.workflowInstanceId, {
137
- activationId: recovery.item.activation.activationId,
138
- runId: recovery.run.runId,
139
- reason: recovery.run.failure?.message ?? 'execution failed',
140
- }, context(recovery.run.runId));
141
- return {
142
- kind: WorkflowStatus.Blocked,
143
- workflowInstanceId: recovery.item.workflow.workflowInstanceId,
144
- reason: recovery.run.failure?.message ?? 'execution failed',
145
- };
148
+ catch (error) {
149
+ if (await advancementWasDurablyApplied(orchestration, recovery.item.workflow.workflowInstanceId, recovery.item.activation.activationId))
150
+ throw error;
151
+ const reason = await blockIsolatedWorkflow(orchestration, recovery.item.workflow.workflowInstanceId, error, context(recovery.run.runId));
152
+ const result = await runDispatchLoop(pending.filter((item) => item.workflow.workflowInstanceId !== recovery.item.workflow.workflowInstanceId), {
153
+ orchestration,
154
+ execution,
155
+ resources,
156
+ dispatchPolicy,
157
+ maxConcurrentRuns,
158
+ maxDispatches,
159
+ runnerIneligibility,
160
+ isDispatchPaused,
161
+ commandContext: context,
162
+ });
163
+ return result.kind === 'progressed'
164
+ ? result
165
+ : {
166
+ kind: WorkflowStatus.Blocked,
167
+ workflowInstanceId: recovery.item.workflow.workflowInstanceId,
168
+ reason,
169
+ };
170
+ }
146
171
  }
147
172
  const result = await runDispatchLoop(pending, {
148
173
  orchestration,
@@ -20,6 +20,9 @@ interface PendingActivation {
20
20
  readonly workflow: WorkflowInstanceView;
21
21
  readonly activation: ActivityActivationView;
22
22
  }
23
+ export declare function advancementWasDurablyApplied(orchestration: OrchestrationPort, workflowInstanceId: WorkflowInstanceView['workflowInstanceId'], activationId: ActivityActivationView['activationId']): Promise<boolean>;
24
+ export declare function isolatedWorkflowReason(error: unknown): string;
25
+ export declare function blockIsolatedWorkflow(orchestration: OrchestrationPort, workflowInstanceId: string, error: unknown, context: CommandContext): Promise<string>;
23
26
  export declare function isRunnerQuotaOutcome(outcome: ActivityOutcome): boolean;
24
27
  export declare function runnerQuotaMessage(outcome: ActivityOutcome): string;
25
28
  /**
@@ -2,6 +2,24 @@ import { ActivityFailureCode, ActivityOutcomeKind, } from '../../activities/inde
2
2
  import { ActivationClaimConflictError, isActiveRunStatus, NoEligibleRunnerError, RunStatus, WorkspaceMode, } from '../../execution/index.js';
3
3
  import { WorkflowStatus } from '../../orchestration/index.js';
4
4
  import { isExecutionFailureTerminal } from './execution-reconciliation.js';
5
+ export async function advancementWasDurablyApplied(orchestration, workflowInstanceId, activationId) {
6
+ const current = await orchestration.get?.(workflowInstanceId);
7
+ return (current !== undefined && current !== null && current.acceptedOutcomes.includes(activationId));
8
+ }
9
+ const maximumIsolationReasonLength = 2_000;
10
+ export function isolatedWorkflowReason(error) {
11
+ const message = error instanceof Error ? error.message : String(error);
12
+ return `workflow advancement failed: ${message}`.slice(0, maximumIsolationReasonLength);
13
+ }
14
+ export async function blockIsolatedWorkflow(orchestration, workflowInstanceId, error, context) {
15
+ const reason = isolatedWorkflowReason(error);
16
+ if (orchestration.block === undefined)
17
+ throw new Error('Orchestration port cannot durably block an isolated workflow', {
18
+ cause: error,
19
+ });
20
+ await orchestration.block(workflowInstanceId, reason, context);
21
+ return reason;
22
+ }
5
23
  export function isRunnerQuotaOutcome(outcome) {
6
24
  return (outcome.kind === ActivityOutcomeKind.Failed &&
7
25
  typeof outcome.data === 'object' &&
@@ -37,6 +55,8 @@ export function reportUnappliedRunnerQuotaRetry(result, input) {
37
55
  export async function runDispatchLoop(pending, ctx) {
38
56
  const dispatched = [];
39
57
  const dispatchedIds = new Set();
58
+ const isolatedIds = new Set();
59
+ let isolated;
40
60
  let stopReason;
41
61
  while (dispatched.length < ctx.maxDispatches) {
42
62
  const allRuns = await ctx.execution.list();
@@ -48,7 +68,9 @@ export async function runDispatchLoop(pending, ctx) {
48
68
  stopReason = { kind: 'no-work' };
49
69
  break;
50
70
  }
51
- const selectedCandidate = ctx.dispatchPolicy.select(await Promise.all(pending.map(async (item, requestedPosition) => ({
71
+ const selectedCandidate = ctx.dispatchPolicy.select(pending
72
+ .filter((item) => !isolatedIds.has(item.workflow.workflowInstanceId))
73
+ .map((item, requestedPosition) => ({
52
74
  workItemId: item.workflow.workItemId,
53
75
  activationId: item.activation.activationId,
54
76
  requestedPosition,
@@ -56,7 +78,7 @@ export async function runDispatchLoop(pending, ctx) {
56
78
  activeActivationIds.has(item.activation.activationId) ||
57
79
  branchActiveWorkflowIds.has(item.workflow.workflowInstanceId),
58
80
  cancelled: false,
59
- }))))[0];
81
+ })))[0];
60
82
  const selected = selectedCandidate === undefined
61
83
  ? undefined
62
84
  : pending.find((item) => item.activation.activationId === selectedCandidate.activationId);
@@ -73,17 +95,16 @@ export async function runDispatchLoop(pending, ctx) {
73
95
  stopReason = { kind: 'paused' };
74
96
  break;
75
97
  }
76
- if ((await ctx.orchestration.validateActivationDispatch?.(selected.workflow.workflowInstanceId, ctx.commandContext(selected.activation.activationId))) === false) {
77
- stopReason = { kind: 'no-work' };
78
- break;
79
- }
80
- await ctx.orchestration.markActivationStarted(selected.workflow.workflowInstanceId, selected.activation.activationId, ctx.commandContext(selected.activation.activationId));
81
- const correlated = await ctx.resources.correlationsForWork(selected.workflow.workItemId);
82
- const resourceViews = (await Promise.all(correlated.map((entry) => ctx.resources.get(entry.resourceId)))).filter((resource) => resource !== null);
83
98
  const ineligible = await ctx.runnerIneligibility();
84
- let run;
85
99
  try {
86
- run = await ctx.execution.attempt(selected.activation, {
100
+ if ((await ctx.orchestration.validateActivationDispatch?.(selected.workflow.workflowInstanceId, ctx.commandContext(selected.activation.activationId))) === false) {
101
+ stopReason = { kind: 'no-work' };
102
+ break;
103
+ }
104
+ await ctx.orchestration.markActivationStarted(selected.workflow.workflowInstanceId, selected.activation.activationId, ctx.commandContext(selected.activation.activationId));
105
+ const correlated = await ctx.resources.correlationsForWork(selected.workflow.workItemId);
106
+ const resourceViews = (await Promise.all(correlated.map((entry) => ctx.resources.get(entry.resourceId)))).filter((resource) => resource !== null);
107
+ const run = await ctx.execution.attempt(selected.activation, {
87
108
  workItemId: selected.workflow.workItemId,
88
109
  workflowInstanceId: selected.workflow.workflowInstanceId,
89
110
  orchestrationGroupId: selected.workflow.orchestrationGroupId,
@@ -95,20 +116,9 @@ export async function runDispatchLoop(pending, ctx) {
95
116
  awaitImmediateCompletion: true,
96
117
  ...(ineligible.size === 0 ? {} : { ineligibleRunners: ineligible }),
97
118
  });
98
- }
99
- catch (error) {
100
- if (error instanceof ActivationClaimConflictError) {
101
- stopReason = { kind: 'no-work' };
102
- break;
103
- }
104
- if (error instanceof NoEligibleRunnerError) {
105
- stopReason = { kind: 'no-work' };
106
- break;
107
- }
108
- throw error;
109
- }
110
- if (run.status === RunStatus.Succeeded && run.outcome !== undefined) {
111
- if (isRunnerQuotaOutcome(run.outcome)) {
119
+ if (run.status === RunStatus.Succeeded &&
120
+ run.outcome !== undefined &&
121
+ isRunnerQuotaOutcome(run.outcome)) {
112
122
  // No isDispatchPaused check here: a quota retry re-requests the same
113
123
  // stage's activity without publishing an outcome or consuming retry
114
124
  // budget, so it must not be held behind maintenance pause the way an
@@ -124,7 +134,7 @@ export async function runDispatchLoop(pending, ctx) {
124
134
  runId: run.runId,
125
135
  });
126
136
  }
127
- else {
137
+ else if (run.status === RunStatus.Succeeded && run.outcome !== undefined) {
128
138
  if (await ctx.isDispatchPaused()) {
129
139
  stopReason = { kind: 'paused' };
130
140
  break;
@@ -135,25 +145,45 @@ export async function runDispatchLoop(pending, ctx) {
135
145
  outcome: run.outcome,
136
146
  }, ctx.commandContext(run.runId));
137
147
  }
148
+ if (isExecutionFailureTerminal(run.status))
149
+ await ctx.orchestration.resolveExecutionFailure?.(selected.workflow.workflowInstanceId, {
150
+ activationId: selected.activation.activationId,
151
+ runId: run.runId,
152
+ reason: run.failure?.message ?? 'execution failed',
153
+ }, ctx.commandContext(run.runId));
154
+ if (run.status !== RunStatus.Succeeded && !isActiveRunStatus(run.status)) {
155
+ stopReason = {
156
+ kind: WorkflowStatus.Blocked,
157
+ workflowInstanceId: selected.workflow.workflowInstanceId,
158
+ reason: run.failure?.message ?? 'execution failed',
159
+ };
160
+ break;
161
+ }
162
+ dispatched.push({ activationId: selected.activation.activationId, runId: run.runId });
163
+ dispatchedIds.add(selected.activation.activationId);
138
164
  }
139
- if (isExecutionFailureTerminal(run.status))
140
- await ctx.orchestration.resolveExecutionFailure?.(selected.workflow.workflowInstanceId, {
141
- activationId: selected.activation.activationId,
142
- runId: run.runId,
143
- reason: run.failure?.message ?? 'execution failed',
144
- }, ctx.commandContext(run.runId));
145
- if (run.status !== RunStatus.Succeeded && !isActiveRunStatus(run.status)) {
146
- stopReason = {
165
+ catch (error) {
166
+ if (error instanceof ActivationClaimConflictError) {
167
+ stopReason = { kind: 'no-work' };
168
+ break;
169
+ }
170
+ if (error instanceof NoEligibleRunnerError) {
171
+ stopReason = { kind: 'no-work' };
172
+ break;
173
+ }
174
+ if (await advancementWasDurablyApplied(ctx.orchestration, selected.workflow.workflowInstanceId, selected.activation.activationId))
175
+ throw error;
176
+ const reason = await blockIsolatedWorkflow(ctx.orchestration, selected.workflow.workflowInstanceId, error, ctx.commandContext(selected.activation.activationId));
177
+ isolatedIds.add(selected.workflow.workflowInstanceId);
178
+ isolated = {
147
179
  kind: WorkflowStatus.Blocked,
148
180
  workflowInstanceId: selected.workflow.workflowInstanceId,
149
- reason: run.failure?.message ?? 'execution failed',
181
+ reason,
150
182
  };
151
- break;
183
+ continue;
152
184
  }
153
- dispatched.push({ activationId: selected.activation.activationId, runId: run.runId });
154
- dispatchedIds.add(selected.activation.activationId);
155
185
  }
156
186
  return dispatched.length > 0
157
187
  ? { kind: 'progressed', dispatched }
158
- : (stopReason ?? { kind: 'no-work' });
188
+ : (stopReason ?? isolated ?? { kind: 'no-work' });
159
189
  }
@@ -18,23 +18,19 @@ durable flat JSONL segment format, while checkpoints, processor state,
18
18
  projections, locks, and processor serialisation use compatible atomic
19
19
  filesystem operations.
20
20
 
21
- `FileProjectionStore` is exclusively for rebuildable Eventing read models.
22
- `FileProcessorStateStore` persists processor-owned recovery state separately.
23
- When processor state uses established compatible paths beneath `projections/`,
24
- pass each state consumer to `FileProjectionStore` so a projection rebuild
25
- reserves its current, legacy, and collision-isolated directories:
21
+ `FileProjectionStore` is exclusively for rebuildable Eventing read models and
22
+ `FileProcessorStateStore` persists processor-owned recovery state separately:
26
23
 
27
24
  ```ts
28
- const projections = new FileProjectionStore(dataRoot, {
29
- protectedProcessorStateConsumers: ['reactor:delivery-outcomes'],
30
- });
25
+ const projections = new FileProjectionStore(dataRoot);
31
26
  const processorState = new FileProcessorStateStore(dataRoot);
32
27
  ```
33
28
 
34
- This is structural path ownership; projection clearing never infers state from
35
- record contents. Wake Bootstrap supplies its known processor consumers.
36
- The adapter reads established Wake-home records without a data migration, but
37
- does not supply a legacy source import, a compatibility package, or a second
38
- runtime path. All supported imports come from
39
- `@atolis-hq/eventing-filesystem`; internal codecs and storage helpers are not
40
- public API.
29
+ Projection records are stored at
30
+ `projections/projection/<namespace-sha256>/<key-sha256>.json`; processor state
31
+ is stored at `projections/processor-state/<consumer-sha256>/<key-sha256>.json`.
32
+ `FileProjectionStore.clear()` removes only the rebuildable `projection` subtree
33
+ and never infers processor state from record contents. This is a fresh-storage
34
+ format: the adapter does not read, migrate, or write prior projection paths.
35
+ All supported imports come from `@atolis-hq/eventing-filesystem`; internal
36
+ codecs and storage helpers are not public API.
@@ -6,7 +6,7 @@ export declare class FileProcessorStateStore implements ProcessorStateStore {
6
6
  write<Value>(state: StoredProcessorState<Value>): Promise<void>;
7
7
  delete(consumer: string, key: string): Promise<void>;
8
8
  private paths;
9
- private withStateLocks;
10
- private readCandidates;
9
+ private withStateLock;
10
+ private readState;
11
11
  }
12
12
  //# sourceMappingURL=file-processor-state-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-processor-state-store.d.ts","sourceRoot":"","sources":["../src/file-processor-state-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAcrF,qBAAa,uBAAwB,YAAW,mBAAmB;IACrD,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,MAAM;IAEnC,IAAI,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IAWvF,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,oBAAoB,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAkB/D,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU1D,OAAO,CAAC,KAAK;YAIC,cAAc;YAOd,cAAc;CAY7B"}
1
+ {"version":3,"file":"file-processor-state-store.d.ts","sourceRoot":"","sources":["../src/file-processor-state-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAarF,qBAAa,uBAAwB,YAAW,mBAAmB;IACrD,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,MAAM;IAEnC,IAAI,CAAC,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IAQvF,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,oBAAoB,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAY/D,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAS1D,OAAO,CAAC,KAAK;YAIC,aAAa;YAIb,SAAS;CAQxB"}
@@ -5,7 +5,6 @@ import { withFileLock } from './file-lock.js';
5
5
  import { atomicJson } from './file-projection-store.js';
6
6
  import { processorStatePaths } from './processor-state-paths.js';
7
7
  import { isCompatibleProcessorStateRecord, } from './processor-state-record.js';
8
- import { encodeLegacyStorageName } from './storage-name.js';
9
8
  export class FileProcessorStateStore {
10
9
  root;
11
10
  constructor(root) {
@@ -13,69 +12,46 @@ export class FileProcessorStateStore {
13
12
  }
14
13
  async read(consumer, key) {
15
14
  const paths = this.paths(consumer, key);
16
- const candidates = await this.readCandidates(paths);
17
- const stored = candidates.find(({ state }) => matchesStateIdentity(state, paths.namespace, key))?.state;
18
- if (stored !== undefined && stored !== null)
15
+ const stored = await this.readState(paths);
16
+ if (stored !== null && matchesStateIdentity(stored, paths.namespace, key))
19
17
  return { consumer, key, value: stored.value };
20
18
  return null;
21
19
  }
22
20
  async write(state) {
23
21
  const paths = this.paths(state.consumer, state.key);
24
- await this.withStateLocks(candidatePaths(paths), async () => {
25
- const candidates = await this.readCandidates(paths);
26
- const current = candidateState(candidates, paths.current);
22
+ await this.withStateLock(paths.current, async () => {
23
+ const current = await this.readState(paths);
27
24
  if (matchesStateIdentity(current, paths.namespace, state.key))
28
25
  return atomicJson(paths.current, compatibleRecord(paths.namespace, state));
29
- const isolated = candidateState(candidates, paths.isolated);
30
- if (matchesStateIdentity(isolated, paths.namespace, state.key))
31
- return atomicJson(paths.isolated, compatibleRecord(paths.namespace, state));
32
26
  if (current === null)
33
27
  return atomicJson(paths.current, compatibleRecord(paths.namespace, state));
34
- if (isolated === null)
35
- return atomicJson(paths.isolated, compatibleRecord(paths.namespace, state));
36
28
  throw new Error(`Processor state paths are occupied for ${state.consumer}:${state.key}`);
37
29
  });
38
30
  }
39
31
  async delete(consumer, key) {
40
32
  const paths = this.paths(consumer, key);
41
- await this.withStateLocks(candidatePaths(paths), async () => {
42
- const candidates = await this.readCandidates(paths);
43
- for (const { path, state } of candidates) {
44
- if (matchesStateIdentity(state, paths.namespace, key))
45
- await rm(path, { force: true });
46
- }
33
+ await this.withStateLock(paths.current, async () => {
34
+ const state = await this.readState(paths);
35
+ if (matchesStateIdentity(state, paths.namespace, key))
36
+ await rm(paths.current, { force: true });
47
37
  });
48
38
  }
49
39
  paths(consumer, key) {
50
40
  return processorStatePaths(this.root, consumer, key);
51
41
  }
52
- async withStateLocks(paths, operation) {
53
- const locks = uniquePaths(paths)
54
- .map((path) => processorStateLockPath(this.root, path))
55
- .sort();
56
- return withLocks(locks, operation);
42
+ async withStateLock(path, operation) {
43
+ return withFileLock(processorStateLockPath(this.root, path), operation, { waitMs: 5_000 });
57
44
  }
58
- async readCandidates(paths) {
59
- const candidates = await Promise.all(candidatePaths(paths).map(async (path) => ({ path, state: await readCompatibleState(path) })));
60
- for (const { path, state } of candidates) {
61
- if (state !== null)
62
- assertCandidateProvenance(this.root, path, state, paths.namespace, paths.key);
63
- }
64
- return candidates;
45
+ async readState(paths) {
46
+ const state = await readCompatibleState(paths.current);
47
+ if (state !== null && !matchesStateIdentity(state, paths.namespace, paths.key))
48
+ throw invalidProcessorStateRecord(paths.current);
49
+ return state;
65
50
  }
66
51
  }
67
- function legacyProcessorStatePath(root, namespace, key) {
68
- return join(root, 'projections', encodeLegacyStorageName(namespace), `${encodeLegacyStorageName(key)}.json`);
69
- }
70
52
  function processorStateLockPath(root, path) {
71
53
  return join(root, 'locks', 'processor-state', `${createHash('sha256').update(path).digest('hex')}.lock`);
72
54
  }
73
- async function withLocks(paths, operation) {
74
- const [path, ...rest] = paths;
75
- if (path === undefined)
76
- return operation();
77
- return withFileLock(path, () => withLocks(rest, operation), { waitMs: 5_000 });
78
- }
79
55
  async function readCompatibleState(path) {
80
56
  let raw;
81
57
  try {
@@ -103,25 +79,6 @@ function invalidProcessorStateRecord(path, cause) {
103
79
  function matchesStateIdentity(stored, namespace, key) {
104
80
  return stored?.namespace === namespace && stored.key === key;
105
81
  }
106
- function assertCandidateProvenance(root, path, stored, namespace, key) {
107
- if (matchesStateIdentity(stored, namespace, key))
108
- return;
109
- if (legacyProcessorStatePath(root, stored.namespace, stored.key) === path)
110
- return;
111
- throw invalidProcessorStateRecord(path);
112
- }
113
82
  function compatibleRecord(namespace, state) {
114
83
  return { namespace, key: state.key, lastGlobalPosition: 0, value: state.value };
115
84
  }
116
- function uniquePaths(paths) {
117
- return [...new Set(paths)];
118
- }
119
- function candidatePaths(paths) {
120
- return uniquePaths([paths.current, paths.isolated, paths.legacy]);
121
- }
122
- function candidateState(candidates, path) {
123
- const candidate = candidates.find((value) => value.path === path);
124
- if (candidate === undefined)
125
- throw new Error(`Missing processor state candidate for ${path}`);
126
- return candidate.state;
127
- }
@@ -1,11 +1,7 @@
1
1
  import type { ProjectionStore, StoredProjection } from '@atolis-hq/eventing';
2
- export interface FileProjectionStoreOptions {
3
- readonly protectedProcessorStateConsumers?: readonly string[];
4
- }
5
2
  export declare class FileProjectionStore implements ProjectionStore {
6
3
  private readonly root;
7
- private readonly protectedDirectories;
8
- constructor(root: string, options?: FileProjectionStoreOptions);
4
+ constructor(root: string);
9
5
  private readonly listCache;
10
6
  read<Value>(namespace: string, key: string): Promise<StoredProjection<Value> | null>;
11
7
  write<Value>(projection: StoredProjection<Value>): Promise<void>;
@@ -14,6 +10,5 @@ export declare class FileProjectionStore implements ProjectionStore {
14
10
  private clearProjectionNamespace;
15
11
  private clearAllProjectionDirectories;
16
12
  }
17
- export declare function encode(value: string): string;
18
13
  export declare function atomicJson(path: string, value: unknown): Promise<void>;
19
14
  //# sourceMappingURL=file-projection-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-projection-store.d.ts","sourceRoot":"","sources":["../src/file-projection-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAQ7E,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,gCAAgC,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/D;AAED,qBAAa,mBAAoB,YAAW,eAAe;IAIvD,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHvB,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAsB;gBAGxC,IAAI,EAAE,MAAM,EAC7B,OAAO,GAAE,0BAA+B;IAO1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgD;IAEpE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IAapF,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,gBAAgB,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IA2ChE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC;IAoB3E,KAAK,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAOhC,wBAAwB;YAaxB,6BAA6B;CAiB5C;AAwJD,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE5C;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAE5E"}
1
+ {"version":3,"file":"file-projection-store.d.ts","sourceRoot":"","sources":["../src/file-projection-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAO7E,qBAAa,mBAAoB,YAAW,eAAe;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,MAAM;IAEzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgD;IAEpE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IAUpF,KAAK,CAAC,KAAK,EAAE,UAAU,EAAE,gBAAgB,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IA2ChE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC;IAoB3E,KAAK,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAOhC,wBAAwB;YAIxB,6BAA6B;CAG5C;AAgID,wBAAsB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAE5E"}
@@ -1,22 +1,17 @@
1
1
  import { readFile, readdir, rm, stat } from 'node:fs/promises';
2
- import { basename, dirname, join } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
3
  import { writeFileAtomically } from './atomic-write.js';
4
- import { processorStateDirectoryNames } from './processor-state-paths.js';
5
- import { encodeLegacyStorageName, encodeStorageName } from './storage-name.js';
4
+ import { projectionStorageAddress } from './storage-name.js';
6
5
  export class FileProjectionStore {
7
6
  root;
8
- protectedDirectories;
9
- constructor(root, options = {}) {
7
+ constructor(root) {
10
8
  this.root = root;
11
- this.protectedDirectories = new Set((options.protectedProcessorStateConsumers ?? []).flatMap(processorStateDirectoryNames));
12
9
  }
13
10
  listCache = new Map();
14
11
  async read(namespace, key) {
15
- for (const path of projectionPaths(this.root, namespace, key).candidates) {
16
- const projection = await readProjection(path);
17
- if (matchesProjection(projection, namespace, key))
18
- return projection;
19
- }
12
+ const projection = await readProjection(projectionPaths(this.root, namespace, key).current);
13
+ if (matchesProjection(projection, namespace, key))
14
+ return projection;
20
15
  return null;
21
16
  }
22
17
  // Patches the cached entry in place rather than invalidating the whole
@@ -92,57 +87,21 @@ export class FileProjectionStore {
92
87
  this.listCache.delete(namespace);
93
88
  }
94
89
  async clearProjectionNamespace(namespace) {
95
- const [currentDirectory, ...legacyDirectories] = projectionDirectories(this.root, namespace);
96
- // The versioned directory belongs exclusively to this namespace, unlike a
97
- // legacy directory that can collide with another namespace's spelling.
98
- await rm(currentDirectory, { recursive: true, force: true });
99
- await Promise.all(legacyDirectories.map(async (directory) => {
100
- if (this.protectedDirectories.has(directoryName(directory)))
101
- return;
102
- await clearProjectionDirectory(directory, namespace);
103
- }));
90
+ await rm(projectionDirectories(this.root, namespace)[0], { recursive: true, force: true });
104
91
  }
105
92
  async clearAllProjectionDirectories() {
106
- const root = join(this.root, 'projections');
107
- let namespaces;
108
- try {
109
- namespaces = await readdir(root);
110
- }
111
- catch (error) {
112
- if (error.code === 'ENOENT')
113
- return;
114
- throw error;
115
- }
116
- await Promise.all(namespaces.map(async (namespace) => {
117
- const directory = join(root, namespace);
118
- if (this.protectedDirectories.has(namespace))
119
- return;
120
- await rm(directory, { recursive: true, force: true });
121
- }));
93
+ await rm(join(this.root, 'projections', 'projection'), { recursive: true, force: true });
122
94
  }
123
95
  }
124
96
  function directoryRevision(info) {
125
97
  return `${info.mtimeMs}:${info.ctimeMs}`;
126
98
  }
127
99
  function projectionPaths(root, namespace, key) {
128
- const currentNamespace = encode(namespace);
129
- const currentKey = encode(key);
130
- const legacyNamespace = encodeLegacyStorageName(namespace);
131
- const legacyKey = encodeLegacyStorageName(key);
132
- const current = projectionPath(root, `%projection-${currentNamespace}`, `%projection-${currentKey}`);
133
- const legacy = projectionPath(root, legacyNamespace, legacyKey);
134
- return {
135
- current,
136
- legacy,
137
- candidates: uniquePaths([current, legacy]),
138
- };
100
+ const current = projectionPath(root, join('projection', projectionStorageAddress(namespace)), projectionStorageAddress(key));
101
+ return { current };
139
102
  }
140
103
  function projectionDirectories(root, namespace) {
141
- const currentNamespace = encode(namespace);
142
- return uniquePaths([
143
- join(root, 'projections', `%projection-${currentNamespace}`),
144
- join(root, 'projections', encodeLegacyStorageName(namespace)),
145
- ]);
104
+ return [join(root, 'projections', 'projection', projectionStorageAddress(namespace))];
146
105
  }
147
106
  function projectionPath(root, namespace, key) {
148
107
  return join(root, 'projections', namespace, `${key}.json`);
@@ -197,22 +156,15 @@ function sameDirectoryRevisions(left, right) {
197
156
  left.every((entry, index) => entry.directory === right[index].directory && entry.value === right[index].value));
198
157
  }
199
158
  function compareCachedProjectionFiles(left, right) {
200
- return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
201
- }
202
- async function clearProjectionDirectory(directory, namespace) {
203
- const entries = await readProjectionDirectory(directory);
204
- await Promise.all(entries
205
- .filter((entry) => matchesProjection(entry.value, namespace))
206
- .map((entry) => rm(entry.path, { force: true })));
207
- }
208
- function uniquePaths(paths) {
209
- return [...new Set(paths)];
210
- }
211
- function directoryName(path) {
212
- return basename(path);
213
- }
214
- export function encode(value) {
215
- return encodeStorageName(value);
159
+ return left.value.key < right.value.key
160
+ ? -1
161
+ : left.value.key > right.value.key
162
+ ? 1
163
+ : left.path < right.path
164
+ ? -1
165
+ : left.path > right.path
166
+ ? 1
167
+ : 0;
216
168
  }
217
169
  export async function atomicJson(path, value) {
218
170
  await writeFileAtomically(path, `${JSON.stringify(value)}\n`);
@@ -3,5 +3,5 @@ export { FileEventJournal, type FileEventJournalOptions, type FileEventJournalWa
3
3
  export { acquireFileLock, withFileLock, type FileLockMetadata, type FileLockOptions, type WithFileLockOptions, } from './file-lock.js';
4
4
  export { createFileProcessorRunSerialiser } from './file-processor-run-serialiser.js';
5
5
  export { FileProcessorStateStore } from './file-processor-state-store.js';
6
- export { FileProjectionStore, type FileProjectionStoreOptions } from './file-projection-store.js';
6
+ export { FileProjectionStore } from './file-projection-store.js';
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,uBAAuB,EAC5B,KAAK,8BAA8B,GACpC,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EACL,eAAe,EACf,YAAY,EACZ,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,gCAAgC,EAAE,MAAM,oCAAoC,CAAC;AAEtF,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAE,mBAAmB,EAAE,KAAK,0BAA0B,EAAE,MAAM,4BAA4B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEjE,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,uBAAuB,EAC5B,KAAK,8BAA8B,GACpC,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EACL,eAAe,EACf,YAAY,EACZ,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,gCAAgC,EAAE,MAAM,oCAAoC,CAAC;AAEtF,OAAO,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC"}
@@ -2,9 +2,6 @@ export interface ProcessorStatePaths {
2
2
  readonly key: string;
3
3
  readonly namespace: string;
4
4
  readonly current: string;
5
- readonly isolated: string;
6
- readonly legacy: string;
7
5
  }
8
6
  export declare function processorStatePaths(root: string, consumer: string, key: string): ProcessorStatePaths;
9
- export declare function processorStateDirectoryNames(consumer: string): readonly string[];
10
7
  //# sourceMappingURL=processor-state-paths.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"processor-state-paths.d.ts","sourceRoot":"","sources":["../src/processor-state-paths.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV,mBAAmB,CAmBrB;AAED,wBAAgB,4BAA4B,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAIhF"}
1
+ {"version":3,"file":"processor-state-paths.d.ts","sourceRoot":"","sources":["../src/processor-state-paths.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE,MAAM,GACV,mBAAmB,CAcrB"}
@@ -1,27 +1,12 @@
1
1
  import { join } from 'node:path';
2
- import { assertStorageName, encodeLegacyStorageName, encodeStorageName } from './storage-name.js';
2
+ import { assertStorageName, projectionStorageAddress } from './storage-name.js';
3
3
  const pendingNamespaceSuffix = ':pending';
4
4
  export function processorStatePaths(root, consumer, key) {
5
- const namespace = processorStateNamespace(consumer);
6
- const currentNamespace = encodeStorageName(namespace);
7
- const currentKey = encodeStorageName(key);
5
+ assertStorageName(consumer);
6
+ assertStorageName(key);
8
7
  return {
9
8
  key,
10
- namespace,
11
- current: processorStatePath(root, currentNamespace, currentKey),
12
- isolated: processorStatePath(root, `%processor-state-${currentNamespace}`, `%processor-state-${currentKey}`),
13
- legacy: processorStatePath(root, encodeLegacyStorageName(namespace), encodeLegacyStorageName(key)),
9
+ namespace: `${consumer}${pendingNamespaceSuffix}`,
10
+ current: join(root, 'projections', 'processor-state', projectionStorageAddress(consumer), `${projectionStorageAddress(key)}.json`),
14
11
  };
15
12
  }
16
- export function processorStateDirectoryNames(consumer) {
17
- const namespace = processorStateNamespace(consumer);
18
- const current = encodeStorageName(namespace);
19
- return [...new Set([current, `%processor-state-${current}`, encodeLegacyStorageName(namespace)])];
20
- }
21
- function processorStateNamespace(consumer) {
22
- assertStorageName(consumer);
23
- return `${consumer}${pendingNamespaceSuffix}`;
24
- }
25
- function processorStatePath(root, namespace, key) {
26
- return join(root, 'projections', namespace, `${key}.json`);
27
- }
@@ -1,6 +1,6 @@
1
1
  export declare function assertWellFormedUtf16(value: string, label: string): void;
2
2
  export declare function assertStorageName(value: string): void;
3
- export declare function encodeStorageName(value: string): string;
4
3
  export declare function encodeLegacyStorageName(value: string): string;
5
4
  export declare function encodeCheckpointStorageName(consumer: string): string;
5
+ export declare function projectionStorageAddress(value: string): string;
6
6
  //# sourceMappingURL=storage-name.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"storage-name.d.ts","sourceRoot":"","sources":["../src/storage-name.ts"],"names":[],"mappings":"AAAA,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAYxE;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAIrD;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMvD;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGpE"}
1
+ {"version":3,"file":"storage-name.d.ts","sourceRoot":"","sources":["../src/storage-name.ts"],"names":[],"mappings":"AAEA,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAYxE;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAIrD;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGpE;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG9D"}
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  export function assertWellFormedUtf16(value, label) {
2
3
  for (let index = 0; index < value.length; index += 1) {
3
4
  const codeUnit = value.charCodeAt(index);
@@ -17,13 +18,6 @@ export function assertStorageName(value) {
17
18
  if (value.length === 0 || /[\\/]/.test(value))
18
19
  throw new Error('Storage name must not contain path separators');
19
20
  }
20
- export function encodeStorageName(value) {
21
- assertStorageName(value);
22
- return encodeURIComponent(value)
23
- .replace(/~/g, '%7E')
24
- .replace(/%(?!7E)/g, '~')
25
- .replace(/\./g, '~2E');
26
- }
27
21
  export function encodeLegacyStorageName(value) {
28
22
  assertStorageName(value);
29
23
  return encodeURIComponent(value).replace(/%/g, '~').replace(/\./g, '~2E');
@@ -32,3 +26,7 @@ export function encodeCheckpointStorageName(consumer) {
32
26
  assertStorageName(consumer);
33
27
  return Buffer.from(consumer, 'utf8').toString('base64url');
34
28
  }
29
+ export function projectionStorageAddress(value) {
30
+ assertStorageName(value);
31
+ return createHash('sha256').update(value, 'utf8').digest('base64url');
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.4.43",
3
+ "version": "0.4.45",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {