@abelxiaoxing/cadence 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -635,6 +635,8 @@ export class WorkflowEngine {
635
635
  let lease: OperationLease;
636
636
  try {
637
637
  lease = this.#operationLease(runId, operationId);
638
+ if (this.#operation(runId, operationId)?.command !== "amend")
639
+ this.#pauseInterruptedRun(runId, operationId, lease);
638
640
  this.#runStore.interruptLease(lease);
639
641
  this.#database
640
642
  .prepare(
@@ -678,6 +680,64 @@ export class WorkflowEngine {
678
680
  });
679
681
  }
680
682
 
683
+ #pauseInterruptedRun(
684
+ runId: string,
685
+ operationId: string,
686
+ lease: OperationLease,
687
+ ): void {
688
+ const projection = this.#runStore.status(runId);
689
+ if (["completed", "discarded", "rejected"].includes(projection.state))
690
+ return;
691
+ const write = (database: DatabaseSync) => {
692
+ database
693
+ .prepare(`UPDATE workflow_engine_tasks
694
+ SET state = 'paused',
695
+ pause_code = CASE WHEN pause_code = 'red-artifact-constraint'
696
+ THEN pause_code ELSE 'operation-interrupted' END,
697
+ context_request_json = CASE WHEN pause_code = 'red-artifact-constraint'
698
+ THEN context_request_json ELSE NULL END,
699
+ queue_position = NULL
700
+ WHERE run_id = ? AND state IN ('phase-running', 'validating')`)
701
+ .run(runId);
702
+ };
703
+ const active = [
704
+ "created",
705
+ "validating-delivery",
706
+ "ready",
707
+ "queued",
708
+ "connecting",
709
+ "running",
710
+ "validating",
711
+ "verifying",
712
+ "retryable",
713
+ "approval-needed",
714
+ "change-verifying",
715
+ "ready-to-apply",
716
+ ];
717
+ const to =
718
+ projection.state === "applying"
719
+ ? "recovering"
720
+ : active.includes(projection.state)
721
+ ? "paused"
722
+ : undefined;
723
+ if (to) {
724
+ // Keep task and run projections atomic; retained evidence and budgets are
725
+ // deliberately untouched. The original exception still reaches the caller.
726
+ this.#runStore.transitionAtomically(
727
+ {
728
+ runId,
729
+ to,
730
+ lease,
731
+ code: "operation-interrupted",
732
+ operationId: `eng-${hash(runId, operationId, String(projection.sequence), "interrupted").slice(0, 40)}`,
733
+ },
734
+ write,
735
+ );
736
+ } else {
737
+ this.#leasedTransaction(lease, () => write(this.#database));
738
+ }
739
+ }
740
+
681
741
  #recoverInterruptedOperations(): void {
682
742
  if (this.#orphanRecoveryTimer) {
683
743
  clearTimeout(this.#orphanRecoveryTimer);
@@ -687,7 +747,11 @@ export class WorkflowEngine {
687
747
  const interrupted = this.#database
688
748
  .prepare(
689
749
  `SELECT run_id, operation_id FROM workflow_engine_operations
690
- WHERE state = 'running'`,
750
+ WHERE state = 'running' OR (state = 'interrupted' AND command <> 'amend'
751
+ AND (run_id IN (SELECT run_id FROM workflow_engine_tasks
752
+ WHERE state IN ('phase-running', 'validating'))
753
+ OR run_id IN (SELECT run_id FROM runs WHERE state IN
754
+ ('running', 'validating', 'verifying', 'change-verifying', 'applying'))))`,
691
755
  )
692
756
  .all() as unknown as Array<{ run_id: string; operation_id: string }>;
693
757
  for (const row of interrupted) {
@@ -724,57 +788,28 @@ export class WorkflowEngine {
724
788
  WHERE run_id = ? AND operation_id = ? AND state = 'running'`,
725
789
  )
726
790
  .run(row.run_id, row.operation_id);
727
- const projection = this.#runStore.status(row.run_id);
728
- if (["completed", "discarded", "rejected"].includes(projection.state)) {
729
- continue;
730
- }
731
- this.#database
732
- .prepare(
733
- `UPDATE workflow_engine_tasks
734
- SET state = 'paused',
735
- pause_code = CASE
736
- WHEN pause_code = 'red-artifact-constraint'
737
- THEN pause_code
738
- ELSE 'operation-interrupted'
739
- END,
740
- context_request_json = CASE
741
- WHEN pause_code = 'red-artifact-constraint'
742
- THEN context_request_json
743
- ELSE NULL
744
- END,
745
- queue_position = NULL
746
- WHERE run_id = ? AND state IN ('phase-running', 'validating')`,
791
+ // Claim the same run-exclusive lease before repairing projections. A live
792
+ // owner (including a different host) must never be mistaken for an orphan.
793
+ let recoveryLease: OperationLease;
794
+ try {
795
+ recoveryLease = this.#runStore.acquireLease({
796
+ runId: row.run_id,
797
+ operationId: this.#leaseOperationId(row.run_id, row.operation_id),
798
+ ttlMs: this.#leaseTtlMs,
799
+ exclusive: true,
800
+ });
801
+ } catch (error) {
802
+ if (
803
+ error instanceof Error &&
804
+ error.message === "operation-already-running"
747
805
  )
748
- .run(row.run_id);
749
- if (projection.state === "applying") {
750
- this.#transition(
751
- row.run_id,
752
- "recovering",
753
- "restart-apply-recovery",
754
- "operation-interrupted",
755
- );
756
- } else if (
757
- [
758
- "created",
759
- "validating-delivery",
760
- "ready",
761
- "queued",
762
- "connecting",
763
- "running",
764
- "validating",
765
- "verifying",
766
- "retryable",
767
- "approval-needed",
768
- "change-verifying",
769
- "ready-to-apply",
770
- ].includes(projection.state)
771
- ) {
772
- this.#transition(
773
- row.run_id,
774
- "paused",
775
- "restart-operation-interrupted",
776
- "operation-interrupted",
777
- );
806
+ continue;
807
+ throw error;
808
+ }
809
+ try {
810
+ this.#pauseInterruptedRun(row.run_id, row.operation_id, recoveryLease);
811
+ } finally {
812
+ this.#runStore.interruptLease(recoveryLease);
778
813
  }
779
814
  }
780
815
  if (nextExpiry !== undefined && !this.#closed) {
@@ -7,19 +7,41 @@ import { parentPort, workerData } from "node:worker_threads";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import {
9
9
  chmodSync,
10
- closeSync,
11
- constants,
10
+ closeSync as closeSync2,
11
+ constants as constants2,
12
12
  copyFileSync,
13
- fsyncSync,
13
+ fsyncSync as fsyncSync2,
14
14
  lstatSync,
15
15
  mkdirSync,
16
- openSync,
16
+ openSync as openSync2,
17
17
  readFileSync,
18
18
  renameSync,
19
19
  unlinkSync,
20
20
  writeFileSync
21
21
  } from "node:fs";
22
22
  import path from "node:path";
23
+
24
+ // src/directory-sync.ts
25
+ import { closeSync, constants, fstatSync, fsyncSync, openSync } from "node:fs";
26
+ function syncDirectory(directory) {
27
+ const descriptor = openSync(directory, constants.O_RDONLY);
28
+ try {
29
+ try {
30
+ fsyncSync(descriptor);
31
+ return "synced";
32
+ } catch (error) {
33
+ const failure = error;
34
+ if (process.platform === "win32" && failure?.code === "EPERM" && failure.syscall === "fsync" && fstatSync(descriptor).isDirectory()) {
35
+ return "unsupported-directory-barrier";
36
+ }
37
+ throw error;
38
+ }
39
+ } finally {
40
+ closeSync(descriptor);
41
+ }
42
+ }
43
+
44
+ // src/artifact-store.ts
23
45
  var SHA256 = /^[a-f0-9]{64}$/u;
24
46
  function digest(bytes) {
25
47
  return createHash("sha256").update(bytes).digest("hex");
@@ -55,24 +77,16 @@ function ensureDirectory(directory) {
55
77
  }
56
78
  chmodSync(directory, 448);
57
79
  }
58
- function syncDirectory(directory) {
59
- const descriptor = openSync(directory, constants.O_RDONLY);
60
- try {
61
- fsyncSync(descriptor);
62
- } finally {
63
- closeSync(descriptor);
64
- }
65
- }
66
80
  function writeAtomic(target, bytes) {
67
81
  const directory = path.dirname(target);
68
82
  ensureDirectory(directory);
69
83
  const temporary = path.join(directory, `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`);
70
- const descriptor = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 384);
84
+ const descriptor = openSync2(temporary, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
71
85
  try {
72
86
  writeFileSync(descriptor, bytes);
73
- fsyncSync(descriptor);
87
+ fsyncSync2(descriptor);
74
88
  } finally {
75
- closeSync(descriptor);
89
+ closeSync2(descriptor);
76
90
  }
77
91
  try {
78
92
  renameSync(temporary, target);
@@ -148,7 +162,7 @@ class ArtifactStore {
148
162
  const stat = lstatSync(source, { throwIfNoEntry: false });
149
163
  if (!stat?.isFile() || stat.isSymbolicLink())
150
164
  throw new Error("artifact-unavailable");
151
- copyFileSync(source, destination, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
165
+ copyFileSync(source, destination, constants2.COPYFILE_EXCL | constants2.COPYFILE_FICLONE);
152
166
  const bytes = readFileSync(destination);
153
167
  if (digest(bytes) !== hash)
154
168
  throw new Error("artifact-integrity-invalid");
@@ -199,7 +213,7 @@ class ArtifactStore {
199
213
 
200
214
  // src/verification-environment-io.ts
201
215
  import {
202
- constants as constants2,
216
+ constants as constants3,
203
217
  cpSync,
204
218
  lstatSync as lstatSync2,
205
219
  mkdirSync as mkdirSync2,
@@ -329,7 +343,7 @@ function prepareVerificationEnvironmentIo(root, dependencyOwner, runnerBindings,
329
343
  checkCancelled();
330
344
  return true;
331
345
  },
332
- mode: constants2.COPYFILE_FICLONE,
346
+ mode: constants3.COPYFILE_FICLONE,
333
347
  verbatimSymlinks: true
334
348
  });
335
349
  };
@@ -410,11 +424,11 @@ function removeEntry(file, checkCancelled) {
410
424
  // src/verification-identity.ts
411
425
  import { createHash as createHash2 } from "node:crypto";
412
426
  import {
413
- closeSync as closeSync2,
414
- constants as constants3,
415
- fstatSync,
427
+ closeSync as closeSync3,
428
+ constants as constants4,
429
+ fstatSync as fstatSync2,
416
430
  lstatSync as lstatSync3,
417
- openSync as openSync2,
431
+ openSync as openSync3,
418
432
  readdirSync as readdirSync2,
419
433
  readlinkSync,
420
434
  readSync
@@ -451,9 +465,9 @@ function verificationEnvironmentDigest(roots, checkCancelled = () => {}) {
451
465
  }
452
466
  if (!stat.isFile())
453
467
  throw new Error("verification-environment-unsafe");
454
- const fd = openSync2(file, constants3.O_RDONLY | constants3.O_NOFOLLOW);
468
+ const fd = openSync3(file, constants4.O_RDONLY | constants4.O_NOFOLLOW);
455
469
  try {
456
- const before = fstatSync(fd);
470
+ const before = fstatSync2(fd);
457
471
  if (before.ino !== stat.ino || before.dev !== stat.dev)
458
472
  throw new Error("verification-environment-changed");
459
473
  const content = createHash2("sha256");
@@ -467,12 +481,12 @@ function verificationEnvironmentDigest(roots, checkCancelled = () => {}) {
467
481
  throw new Error("verification-environment-limit");
468
482
  content.update(buffer.subarray(0, count));
469
483
  }
470
- const after = fstatSync(fd);
484
+ const after = fstatSync2(fd);
471
485
  if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
472
486
  throw new Error("verification-environment-changed");
473
487
  digest2.update(JSON.stringify(["file", content.digest("hex")]));
474
488
  } finally {
475
- closeSync2(fd);
489
+ closeSync3(fd);
476
490
  }
477
491
  };
478
492
  for (const root of [...new Set(roots)].sort())
@@ -485,12 +499,12 @@ import { execFile, execFileSync } from "node:child_process";
485
499
  import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
486
500
  import {
487
501
  chmodSync as chmodSync2,
488
- closeSync as closeSync3,
489
- constants as constants4,
490
- fsyncSync as fsyncSync2,
502
+ closeSync as closeSync4,
503
+ constants as constants5,
504
+ fsyncSync as fsyncSync3,
491
505
  lstatSync as lstatSync5,
492
506
  mkdirSync as mkdirSync3,
493
- openSync as openSync3,
507
+ openSync as openSync4,
494
508
  readFileSync as readFileSync2,
495
509
  renameSync as renameSync2,
496
510
  unlinkSync as unlinkSync2,
@@ -765,29 +779,21 @@ function ensureDirectory2(directory) {
765
779
  }
766
780
  chmodSync2(directory, 448);
767
781
  }
768
- function syncDirectory2(directory) {
769
- const descriptor = openSync3(directory, constants4.O_RDONLY);
770
- try {
771
- fsyncSync2(descriptor);
772
- } finally {
773
- closeSync3(descriptor);
774
- }
775
- }
776
782
  function writeAtomic2(target, bytes) {
777
783
  const directory = path5.dirname(target);
778
784
  ensureDirectory2(directory);
779
785
  const temporary = path5.join(directory, `.${path5.basename(target)}.${process.pid}.${randomUUID2()}.tmp`);
780
- const descriptor = openSync3(temporary, constants4.O_CREAT | constants4.O_EXCL | constants4.O_WRONLY, 384);
786
+ const descriptor = openSync4(temporary, constants5.O_CREAT | constants5.O_EXCL | constants5.O_WRONLY, 384);
781
787
  try {
782
788
  writeFileSync3(descriptor, bytes);
783
- fsyncSync2(descriptor);
789
+ fsyncSync3(descriptor);
784
790
  } finally {
785
- closeSync3(descriptor);
791
+ closeSync4(descriptor);
786
792
  }
787
793
  try {
788
794
  renameSync2(temporary, target);
789
795
  chmodSync2(target, 384);
790
- syncDirectory2(directory);
796
+ syncDirectory(directory);
791
797
  } catch (error) {
792
798
  try {
793
799
  unlinkSync2(temporary);
@@ -18,6 +18,7 @@ import { promisify } from "node:util";
18
18
  import type { ArtifactStore } from "./artifact-store.ts";
19
19
  import { compareCanonicalStrings } from "./canonical.ts";
20
20
  import { isValidRelativePath } from "./contracts.ts";
21
+ import { syncDirectory } from "./directory-sync.ts";
21
22
  import { observeSafePath } from "./safe-path.ts";
22
23
  import { runWorkspaceIo, type WorkspaceIoMetrics } from "./workspace-io.ts";
23
24
 
@@ -127,15 +128,6 @@ function ensureDirectory(directory: string): void {
127
128
  chmodSync(directory, 0o700);
128
129
  }
129
130
 
130
- function syncDirectory(directory: string): void {
131
- const descriptor = openSync(directory, constants.O_RDONLY);
132
- try {
133
- fsyncSync(descriptor);
134
- } finally {
135
- closeSync(descriptor);
136
- }
137
- }
138
-
139
131
  function writeAtomic(target: string, bytes: Uint8Array): void {
140
132
  const directory = path.dirname(target);
141
133
  ensureDirectory(directory);