@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.
- package/README.md +95 -0
- package/package.json +1 -1
- package/src/apply-transaction.ts +1 -9
- package/src/artifact-store.ts +1 -9
- package/src/contracts.ts +16 -5
- package/src/design-diagnostics.ts +8 -0
- package/src/directory-sync.ts +36 -0
- package/src/index.ts +28 -0
- package/src/package-state.ts +309 -0
- package/src/package-workflow.ts +216 -171
- package/src/run-store.ts +39 -15
- package/src/storage-schema.ts +14 -6
- package/src/workflow-state-machine.ts +86 -51
- package/src/workspace-io-worker.mjs +48 -42
- package/src/workspace-store.ts +1 -9
|
@@ -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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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 =
|
|
84
|
+
const descriptor = openSync2(temporary, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY, 384);
|
|
71
85
|
try {
|
|
72
86
|
writeFileSync(descriptor, bytes);
|
|
73
|
-
|
|
87
|
+
fsyncSync2(descriptor);
|
|
74
88
|
} finally {
|
|
75
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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
|
|
414
|
-
constants as
|
|
415
|
-
fstatSync,
|
|
427
|
+
closeSync as closeSync3,
|
|
428
|
+
constants as constants4,
|
|
429
|
+
fstatSync as fstatSync2,
|
|
416
430
|
lstatSync as lstatSync3,
|
|
417
|
-
openSync as
|
|
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 =
|
|
468
|
+
const fd = openSync3(file, constants4.O_RDONLY | constants4.O_NOFOLLOW);
|
|
455
469
|
try {
|
|
456
|
-
const before =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
489
|
-
constants as
|
|
490
|
-
fsyncSync as
|
|
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
|
|
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 =
|
|
786
|
+
const descriptor = openSync4(temporary, constants5.O_CREAT | constants5.O_EXCL | constants5.O_WRONLY, 384);
|
|
781
787
|
try {
|
|
782
788
|
writeFileSync3(descriptor, bytes);
|
|
783
|
-
|
|
789
|
+
fsyncSync3(descriptor);
|
|
784
790
|
} finally {
|
|
785
|
-
|
|
791
|
+
closeSync4(descriptor);
|
|
786
792
|
}
|
|
787
793
|
try {
|
|
788
794
|
renameSync2(temporary, target);
|
|
789
795
|
chmodSync2(target, 384);
|
|
790
|
-
|
|
796
|
+
syncDirectory(directory);
|
|
791
797
|
} catch (error) {
|
|
792
798
|
try {
|
|
793
799
|
unlinkSync2(temporary);
|
package/src/workspace-store.ts
CHANGED
|
@@ -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);
|