@rex0220/ksql-flownet 1.0.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/LICENSE +21 -0
- package/README.ja.md +81 -0
- package/README.md +117 -0
- package/dist/bundle/bundle-builder.d.ts +49 -0
- package/dist/bundle/bundle-builder.js +208 -0
- package/dist/bundle/index.d.ts +4 -0
- package/dist/bundle/index.js +4 -0
- package/dist/bundle/upload.d.ts +28 -0
- package/dist/bundle/upload.js +72 -0
- package/dist/bundle/zip-store.d.ts +17 -0
- package/dist/bundle/zip-store.js +175 -0
- package/dist/cli/archive-run-command.d.ts +12 -0
- package/dist/cli/archive-run-command.js +131 -0
- package/dist/cli/cancel-run-command.d.ts +9 -0
- package/dist/cli/cancel-run-command.js +77 -0
- package/dist/cli/force-unlock-network-command.d.ts +8 -0
- package/dist/cli/force-unlock-network-command.js +129 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +132 -0
- package/dist/cli/plan-command.d.ts +2 -0
- package/dist/cli/plan-command.js +100 -0
- package/dist/cli/poll-requests-command.d.ts +3 -0
- package/dist/cli/poll-requests-command.js +74 -0
- package/dist/cli/record-job-unlock-command.d.ts +11 -0
- package/dist/cli/record-job-unlock-command.js +78 -0
- package/dist/cli/resolve-node-command.d.ts +13 -0
- package/dist/cli/resolve-node-command.js +174 -0
- package/dist/cli/run-network-command.d.ts +14 -0
- package/dist/cli/run-network-command.js +418 -0
- package/dist/cli/status-command.d.ts +5 -0
- package/dist/cli/status-command.js +137 -0
- package/dist/cli/validate-command.d.ts +6 -0
- package/dist/cli/validate-command.js +17 -0
- package/dist/dag/descendants.d.ts +9 -0
- package/dist/dag/descendants.js +67 -0
- package/dist/dag/topological-sort.d.ts +7 -0
- package/dist/dag/topological-sort.js +32 -0
- package/dist/domain/business-key.d.ts +15 -0
- package/dist/domain/business-key.js +249 -0
- package/dist/domain/canonical-lock-key.d.ts +17 -0
- package/dist/domain/canonical-lock-key.js +60 -0
- package/dist/domain/canonical-record-key.d.ts +18 -0
- package/dist/domain/canonical-record-key.js +70 -0
- package/dist/domain/load-network.d.ts +4 -0
- package/dist/domain/load-network.js +47 -0
- package/dist/domain/network-definition.d.ts +47 -0
- package/dist/domain/network-definition.js +7 -0
- package/dist/domain/persistence-model.d.ts +198 -0
- package/dist/domain/persistence-model.js +2 -0
- package/dist/domain/run-aggregate.d.ts +3 -0
- package/dist/domain/run-aggregate.js +15 -0
- package/dist/domain/validate-network-path.d.ts +11 -0
- package/dist/domain/validate-network-path.js +33 -0
- package/dist/domain/validate-network.d.ts +3 -0
- package/dist/domain/validate-network.js +180 -0
- package/dist/executor/attempt-executor.d.ts +43 -0
- package/dist/executor/attempt-executor.js +236 -0
- package/dist/executor/job-log-reader.d.ts +37 -0
- package/dist/executor/job-log-reader.js +91 -0
- package/dist/executor/ksql-flow-cli.d.ts +86 -0
- package/dist/executor/ksql-flow-cli.js +161 -0
- package/dist/executor/preflight.d.ts +41 -0
- package/dist/executor/preflight.js +150 -0
- package/dist/executor/result-classifier.d.ts +69 -0
- package/dist/executor/result-classifier.js +237 -0
- package/dist/executor/run-subprocess.d.ts +64 -0
- package/dist/executor/run-subprocess.js +175 -0
- package/dist/io/input-baseline.d.ts +19 -0
- package/dist/io/input-baseline.js +214 -0
- package/dist/io/io-config.d.ts +6 -0
- package/dist/io/io-config.js +33 -0
- package/dist/io/io-path.d.ts +37 -0
- package/dist/io/io-path.js +279 -0
- package/dist/orchestration/archive-run.d.ts +53 -0
- package/dist/orchestration/archive-run.js +323 -0
- package/dist/orchestration/cancel-request.d.ts +18 -0
- package/dist/orchestration/cancel-request.js +67 -0
- package/dist/orchestration/ensure-run.d.ts +83 -0
- package/dist/orchestration/ensure-run.js +632 -0
- package/dist/orchestration/job-lock-audit.d.ts +24 -0
- package/dist/orchestration/job-lock-audit.js +123 -0
- package/dist/orchestration/orphan-attempt-adjudication.d.ts +39 -0
- package/dist/orchestration/orphan-attempt-adjudication.js +167 -0
- package/dist/orchestration/reconciliation.d.ts +41 -0
- package/dist/orchestration/reconciliation.js +526 -0
- package/dist/orchestration/resolve-node.d.ts +29 -0
- package/dist/orchestration/resolve-node.js +105 -0
- package/dist/orchestration/run-activity.d.ts +13 -0
- package/dist/orchestration/run-activity.js +14 -0
- package/dist/orchestration/sequential-scheduler.d.ts +79 -0
- package/dist/orchestration/sequential-scheduler.js +706 -0
- package/dist/orchestration/status.d.ts +91 -0
- package/dist/orchestration/status.js +171 -0
- package/dist/persistence/in-memory-repository.d.ts +41 -0
- package/dist/persistence/in-memory-repository.js +333 -0
- package/dist/persistence/kintone/client.d.ts +36 -0
- package/dist/persistence/kintone/client.js +93 -0
- package/dist/persistence/kintone/design-notes.d.ts +38 -0
- package/dist/persistence/kintone/design-notes.js +38 -0
- package/dist/persistence/kintone/repository.d.ts +44 -0
- package/dist/persistence/kintone/repository.js +836 -0
- package/dist/persistence/kintone/schema.d.ts +116 -0
- package/dist/persistence/kintone/schema.js +115 -0
- package/dist/persistence/network-lock-reader.d.ts +24 -0
- package/dist/persistence/network-lock-reader.js +43 -0
- package/dist/persistence/network-lock-recovery.d.ts +66 -0
- package/dist/persistence/network-lock-recovery.js +392 -0
- package/dist/persistence/network-lock.d.ts +97 -0
- package/dist/persistence/network-lock.js +408 -0
- package/dist/persistence/repository.d.ts +101 -0
- package/dist/persistence/repository.js +11 -0
- package/dist/persistence/state-transition.d.ts +4 -0
- package/dist/persistence/state-transition.js +25 -0
- package/dist/requests/flownet-child-client.d.ts +78 -0
- package/dist/requests/flownet-child-client.js +243 -0
- package/dist/requests/kintone-request-store.d.ts +44 -0
- package/dist/requests/kintone-request-store.js +202 -0
- package/dist/requests/poll-requests-config.d.ts +26 -0
- package/dist/requests/poll-requests-config.js +105 -0
- package/dist/requests/request-model.d.ts +57 -0
- package/dist/requests/request-model.js +305 -0
- package/dist/requests/request-poller.d.ts +33 -0
- package/dist/requests/request-poller.js +353 -0
- package/dist/requests/request-result.d.ts +18 -0
- package/dist/requests/request-result.js +215 -0
- package/dist/requests/start-request.d.ts +26 -0
- package/dist/requests/start-request.js +83 -0
- package/package.json +63 -0
- package/schemas/.gitkeep +1 -0
- package/schemas/network-definition.schema.json +121 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { buildBundle, readStoreZip, verifyBundle } from "../bundle/index.js";
|
|
5
|
+
import { resolveBusinessKey } from "../domain/business-key.js";
|
|
6
|
+
import { nodeStateKey } from "../domain/canonical-record-key.js";
|
|
7
|
+
import { descendantsIncludingSelf, DescendantsError, } from "../dag/descendants.js";
|
|
8
|
+
import { loadNetworkDefinition, loadNetworkDefinitionSource, } from "../domain/load-network.js";
|
|
9
|
+
import { inputBaselinesEqual, parseInputBaseline, } from "../io/input-baseline.js";
|
|
10
|
+
import { InputPathError, resolveNodeInputs } from "../io/io-path.js";
|
|
11
|
+
import { assertProfileSnapshot, canonicalJsonSha256, profileSnapshot, sha256Hex, validateCapabilities, validateJobInspections, } from "../executor/preflight.js";
|
|
12
|
+
import { RepositoryError } from "../persistence/repository.js";
|
|
13
|
+
import { reconcileRun } from "./reconciliation.js";
|
|
14
|
+
import { isCancelHold } from "./cancel-request.js";
|
|
15
|
+
export class EnsureRunError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
blockedBy;
|
|
18
|
+
constructor(code, message, blockedBy = [], options) {
|
|
19
|
+
super(message, options);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.blockedBy = blockedBy;
|
|
22
|
+
this.name = "EnsureRunError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function ensureRun(input) {
|
|
26
|
+
const now = input.now ?? (() => new Date());
|
|
27
|
+
const uuid = input.uuid ?? randomUUID;
|
|
28
|
+
const loaded = loadNetworkDefinition(input.networkPath);
|
|
29
|
+
if (loaded.definition === undefined || loaded.errors.length > 0) {
|
|
30
|
+
throw new EnsureRunError("NETWORK_VALIDATION_FAILED", formatValidationErrors(loaded.errors));
|
|
31
|
+
}
|
|
32
|
+
const definition = loaded.definition;
|
|
33
|
+
const businessKey = resolveEnsureBusinessKey(input, definition.network_id, definition.business_key_policy, now);
|
|
34
|
+
// Contract 9.1: capability failure must not acquire the Network lock.
|
|
35
|
+
const capabilities = await input.executor.capabilities();
|
|
36
|
+
validateCapabilities(capabilities, [
|
|
37
|
+
...(definition.nodes.some((node) => Object.keys(node.inputs ?? {}).length > 0)
|
|
38
|
+
? ["importCsv"]
|
|
39
|
+
: []),
|
|
40
|
+
...(definition.nodes.some((node) => Object.keys(node.outputs ?? {}).length > 0)
|
|
41
|
+
? ["resultCsv"]
|
|
42
|
+
: []),
|
|
43
|
+
]);
|
|
44
|
+
const configuredIo = await input.beforeLock?.(definition);
|
|
45
|
+
const ioRoot = configuredIo?.root ?? input.ioRoot;
|
|
46
|
+
const ioRetentionDays = configuredIo?.retentionDays ?? input.ioRetentionDays ?? 90;
|
|
47
|
+
let lock = null;
|
|
48
|
+
let invocation = null;
|
|
49
|
+
let handedOff = false;
|
|
50
|
+
try {
|
|
51
|
+
lock = await input.lockManager.acquire();
|
|
52
|
+
let run = await findRunInsideLock(input, definition.network_id, businessKey);
|
|
53
|
+
let outcome;
|
|
54
|
+
let bundleBytes;
|
|
55
|
+
if (run === null) {
|
|
56
|
+
if (input.resumeRunId !== undefined || input.rerunFrom !== undefined) {
|
|
57
|
+
throw new EnsureRunError("RUN_NOT_FOUND", input.resumeRunId === undefined
|
|
58
|
+
? "--rerun-from requires an existing Run selected by --resume"
|
|
59
|
+
: `run '${input.resumeRunId}' was not found`);
|
|
60
|
+
}
|
|
61
|
+
const blockedBy = (await input.repository.listRuns(input.profile, definition.network_id))
|
|
62
|
+
.filter(({ value }) => value.business_key !== businessKey &&
|
|
63
|
+
value.resume_allowed &&
|
|
64
|
+
value.lifecycle_status !== "ARCHIVED" &&
|
|
65
|
+
value.status !== "SUCCESS")
|
|
66
|
+
.map(({ value }) => value.run_id)
|
|
67
|
+
.sort();
|
|
68
|
+
if (blockedBy.length >= definition.max_active_runs) {
|
|
69
|
+
throw new EnsureRunError("MAX_ACTIVE_RUNS", `max_active_runs=${definition.max_active_runs} is reached; blocking run_id(s): ${blockedBy.join(", ")}`, blockedBy);
|
|
70
|
+
}
|
|
71
|
+
const created = await createNewRun(input, definition, businessKey, capabilities, now, uuid, async (createdRun) => {
|
|
72
|
+
invocation = await createInvocation(input, createdRun, "NEW", now, uuid, definition.nodes.map((node) => node.id));
|
|
73
|
+
});
|
|
74
|
+
run = created.run;
|
|
75
|
+
bundleBytes = created.bundleBytes;
|
|
76
|
+
outcome = "NEW";
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
assertRunIdentity(run.value, input.profile, definition.network_id);
|
|
80
|
+
if (run.value.status === "SUCCESS" && input.rerunFrom === undefined) {
|
|
81
|
+
await input.lockManager.release(lock, "SUCCESS", "ALREADY_SUCCESS");
|
|
82
|
+
lock = null;
|
|
83
|
+
return {
|
|
84
|
+
outcome: "NOOP",
|
|
85
|
+
run,
|
|
86
|
+
invocation: null,
|
|
87
|
+
blockedBy: [],
|
|
88
|
+
businessKey: run.value.business_key,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (input.resume !== true &&
|
|
92
|
+
input.resumeRunId === undefined &&
|
|
93
|
+
input.rerunFrom === undefined) {
|
|
94
|
+
throw new EnsureRunError("RUN_ALREADY_EXISTS", `run '${run.value.run_id}' already exists for this business key; use RERUN to continue it`, [run.value.run_id]);
|
|
95
|
+
}
|
|
96
|
+
const cancelRequest = await input.repository.getCancelRequest(run.value.run_id);
|
|
97
|
+
if (isCancelHold(cancelRequest)) {
|
|
98
|
+
throw new EnsureRunError("RUN_ON_HOLD", `run '${run.value.run_id}' is on hold by CANCEL:${run.value.run_id} (${cancelRequest.value.state}, revision ${cancelRequest.revision})`);
|
|
99
|
+
}
|
|
100
|
+
outcome = "RESUME";
|
|
101
|
+
if (input.rerunFrom === undefined &&
|
|
102
|
+
!definition.nodes.some((node) => Object.keys(node.inputs ?? {}).length > 0)) {
|
|
103
|
+
invocation = await createInvocation(input, run, outcome, now, uuid);
|
|
104
|
+
}
|
|
105
|
+
if (!(input.rerunFrom !== undefined && run.value.status === "SUCCESS")) {
|
|
106
|
+
assertRunResumable(run.value);
|
|
107
|
+
}
|
|
108
|
+
const description = await input.executor.describeProfile();
|
|
109
|
+
assertDescriptionProfile(description, input.profile);
|
|
110
|
+
assertStoredProfile(description, run.value);
|
|
111
|
+
bundleBytes = Buffer.from(await input.bundleStore.download(run.value.source_bundle_attachment));
|
|
112
|
+
verifyBundle(bundleBytes, { zipSha256: run.value.source_bundle_sha256 });
|
|
113
|
+
const snapshotDefinition = definitionFromBundle(bundleBytes, run.value);
|
|
114
|
+
assertInputRetention(run.value, snapshotDefinition, ioRetentionDays, now());
|
|
115
|
+
await ensureNodeStates(input.repository, run.value.run_id, snapshotDefinition, now().toISOString(), uuid);
|
|
116
|
+
await reconcileRun(input.repository, run.value.run_id);
|
|
117
|
+
run = await input.repository.getRun(run.value.run_id);
|
|
118
|
+
if (input.rerunFrom !== undefined) {
|
|
119
|
+
const selectedNodeIds = await prepareRerunFrom(input.repository, run, snapshotDefinition, input.rerunFrom, now, (selectedIds) => verifyExistingInputBaselines(input.repository, run.value, snapshotDefinition, new Set(selectedIds), ioRoot, input.profile));
|
|
120
|
+
invocation = await createInvocation(input, run, "RERUN_FROM", now, uuid, selectedNodeIds);
|
|
121
|
+
}
|
|
122
|
+
else if (invocation === null) {
|
|
123
|
+
const states = await input.repository.getNodeStates(run.value.run_id);
|
|
124
|
+
const resumeTargets = new Set(states
|
|
125
|
+
.filter(({ value }) => value.idempotent &&
|
|
126
|
+
(value.status === "FAILED" || value.status === "CANCELLED"))
|
|
127
|
+
.map(({ value }) => value.node_id));
|
|
128
|
+
await verifyExistingInputBaselines(input.repository, run.value, snapshotDefinition, resumeTargets, ioRoot, input.profile);
|
|
129
|
+
invocation = await createInvocation(input, run, outcome, now, uuid);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (invocation === null) {
|
|
133
|
+
invocation = await createInvocation(input, run, outcome, now, uuid);
|
|
134
|
+
}
|
|
135
|
+
if (outcome === "NEW") {
|
|
136
|
+
await reconcileRun(input.repository, run.value.run_id);
|
|
137
|
+
run = await input.repository.getRun(run.value.run_id);
|
|
138
|
+
}
|
|
139
|
+
const activeLock = lock;
|
|
140
|
+
const activeInvocation = invocation;
|
|
141
|
+
let closed = false;
|
|
142
|
+
handedOff = true;
|
|
143
|
+
return {
|
|
144
|
+
outcome,
|
|
145
|
+
run,
|
|
146
|
+
invocation,
|
|
147
|
+
blockedBy: [],
|
|
148
|
+
businessKey: run.value.business_key,
|
|
149
|
+
bundleBytes,
|
|
150
|
+
lock: activeLock,
|
|
151
|
+
async close(finalization) {
|
|
152
|
+
if (closed)
|
|
153
|
+
return;
|
|
154
|
+
closed = true;
|
|
155
|
+
let finalizationError;
|
|
156
|
+
if (finalization.persistInvocation !== false) {
|
|
157
|
+
try {
|
|
158
|
+
await input.repository.finalizeInvocation(activeInvocation.value.invocation_id, activeInvocation.revision, {
|
|
159
|
+
status: finalization.status,
|
|
160
|
+
result_code: finalization.resultCode,
|
|
161
|
+
finished_at: now().toISOString(),
|
|
162
|
+
...(finalization.selectedNodeIds === undefined
|
|
163
|
+
? {}
|
|
164
|
+
: { selected_node_ids: finalization.selectedNodeIds }),
|
|
165
|
+
...(finalization.preservedNodeIds === undefined
|
|
166
|
+
? {}
|
|
167
|
+
: { preserved_node_ids: finalization.preservedNodeIds }),
|
|
168
|
+
...(finalization.blockedNodeIds === undefined
|
|
169
|
+
? {}
|
|
170
|
+
: { blocked_node_ids: finalization.blockedNodeIds }),
|
|
171
|
+
...(finalization.reason === undefined
|
|
172
|
+
? {}
|
|
173
|
+
: {
|
|
174
|
+
reason: `${activeInvocation.value.reason}; ${finalization.reason}`,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
finalizationError = error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
await input.lockManager.release(activeLock, finalization.status, finalization.resultCode);
|
|
184
|
+
}
|
|
185
|
+
catch (releaseError) {
|
|
186
|
+
throw new AggregateError(finalizationError === undefined
|
|
187
|
+
? [releaseError]
|
|
188
|
+
: [finalizationError, releaseError], "ensure-run close failed", { cause: releaseError });
|
|
189
|
+
}
|
|
190
|
+
if (finalizationError !== undefined)
|
|
191
|
+
throw finalizationError;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
let invocationFinalizationError;
|
|
197
|
+
if (invocation !== null) {
|
|
198
|
+
try {
|
|
199
|
+
await input.repository.finalizeInvocation(invocation.value.invocation_id, invocation.revision, {
|
|
200
|
+
status: "FAILED",
|
|
201
|
+
result_code: errorCode(error),
|
|
202
|
+
finished_at: now().toISOString(),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (finalizationError) {
|
|
206
|
+
invocationFinalizationError = finalizationError;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (invocationFinalizationError !== undefined) {
|
|
210
|
+
throw new AggregateError([normalizeEnsureError(error), invocationFinalizationError], "ensure-run failed and its Invocation could not be finalized", { cause: error });
|
|
211
|
+
}
|
|
212
|
+
throw normalizeEnsureError(error);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
if (lock !== null && !handedOff) {
|
|
216
|
+
await input.lockManager.release(lock, "FAILED", "ENSURE_RUN_FAILED");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function findRunInsideLock(input, networkId, businessKey) {
|
|
221
|
+
try {
|
|
222
|
+
if (input.resumeRunId !== undefined) {
|
|
223
|
+
return await input.repository.getRun(input.resumeRunId);
|
|
224
|
+
}
|
|
225
|
+
return await input.repository.getRunByBusinessKey(input.profile, networkId, businessKey);
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (error instanceof RepositoryError && error.code === "MULTIPLE_RECORDS") {
|
|
229
|
+
throw new EnsureRunError("MULTIPLE_RUNS", "multiple runs matched profile + network_id + business_key");
|
|
230
|
+
}
|
|
231
|
+
if (input.resumeRunId !== undefined &&
|
|
232
|
+
error instanceof RepositoryError &&
|
|
233
|
+
error.code === "RECORD_NOT_FOUND") {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function createNewRun(input, definition, businessKey, capabilities, now, uuid, onRunCreated) {
|
|
240
|
+
const description = await input.executor.describeProfile();
|
|
241
|
+
assertDescriptionProfile(description, input.profile);
|
|
242
|
+
const networkBytes = await readFile(input.networkPath);
|
|
243
|
+
const root = dirname(input.networkPath);
|
|
244
|
+
const inspected = await Promise.all(definition.nodes.map(async (node) => {
|
|
245
|
+
const sqlPath = resolve(root, node.sql);
|
|
246
|
+
const before = await readFile(sqlPath);
|
|
247
|
+
const inspection = await input.executor.inspectJob(sqlPath);
|
|
248
|
+
const after = await readFile(sqlPath);
|
|
249
|
+
if (!before.equals(after)) {
|
|
250
|
+
throw new EnsureRunError("BUNDLE_INPUT_CHANGED", `SQL file changed while it was inspected: ${sqlPath}`);
|
|
251
|
+
}
|
|
252
|
+
return { node, sqlBytes: after, inspection };
|
|
253
|
+
}));
|
|
254
|
+
const inspectionMap = new Map(inspected.map(({ node, inspection }) => [node.id, inspection]));
|
|
255
|
+
const validated = validateJobInspections(definition.nodes, inspectionMap);
|
|
256
|
+
const validatedByNode = new Map(validated.map((item) => [item.nodeId, item]));
|
|
257
|
+
const jobs = inspected.map(({ node, sqlBytes }) => ({
|
|
258
|
+
path: `jobs/${node.id}.sql`,
|
|
259
|
+
sqlBytes,
|
|
260
|
+
inspectedNode: validatedByNode.get(node.id),
|
|
261
|
+
}));
|
|
262
|
+
const dialects = [
|
|
263
|
+
...new Set(inspected.map(({ inspection }) => inspection.dialect)),
|
|
264
|
+
];
|
|
265
|
+
if (dialects.length !== 1) {
|
|
266
|
+
throw new EnsureRunError("BUNDLE_DIALECT_MISMATCH", `inspected jobs do not have one dialect: ${dialects.join(", ")}`);
|
|
267
|
+
}
|
|
268
|
+
const bundle = buildBundle({ networkYamlBytes: networkBytes, jobs });
|
|
269
|
+
verifyBundle(bundle.zipBytes, {
|
|
270
|
+
zipSha256: bundle.zipSha256,
|
|
271
|
+
manifestSha256: bundle.manifestSha256,
|
|
272
|
+
manifest: bundle.manifest,
|
|
273
|
+
});
|
|
274
|
+
const uploadFileKey = await input.bundleStore.upload(bundle.zipBytes);
|
|
275
|
+
const at = now().toISOString();
|
|
276
|
+
const snapshot = persistenceProfileSnapshot(description);
|
|
277
|
+
const value = {
|
|
278
|
+
run_id: `netrun_${uuid()}`,
|
|
279
|
+
network_id: definition.network_id,
|
|
280
|
+
business_key: businessKey,
|
|
281
|
+
max_active_runs: definition.max_active_runs,
|
|
282
|
+
status: "CREATED",
|
|
283
|
+
lifecycle_status: "ACTIVE",
|
|
284
|
+
resume_allowed: true,
|
|
285
|
+
as_of: input.scheduledFor ?? null,
|
|
286
|
+
definition_schema_version: definition.schema_version,
|
|
287
|
+
definition_sha256: sha256Hex(networkBytes),
|
|
288
|
+
source_bundle_sha256: bundle.zipSha256,
|
|
289
|
+
source_bundle_attachment: uploadFileKey,
|
|
290
|
+
resolved_profile_snapshot: snapshot,
|
|
291
|
+
resolved_profile_sha256: profileSnapshot(description).canonicalJsonSha256,
|
|
292
|
+
ksql_flow_version: capabilities.ksqlFlowVersion,
|
|
293
|
+
engine_version: capabilities.engineVersion,
|
|
294
|
+
dialect: dialects[0],
|
|
295
|
+
created_at: at,
|
|
296
|
+
started_at: null,
|
|
297
|
+
finished_at: null,
|
|
298
|
+
updated_at: at,
|
|
299
|
+
};
|
|
300
|
+
const created = await input.repository.createRun(value);
|
|
301
|
+
// Re-read after attachment consumption so kintone supplies the downloadable fileKey.
|
|
302
|
+
const stored = await input.repository.getRun(created.value.run_id);
|
|
303
|
+
await onRunCreated(stored);
|
|
304
|
+
const persistedBytes = Buffer.from(await input.bundleStore.download(stored.value.source_bundle_attachment));
|
|
305
|
+
verifyBundle(persistedBytes, { zipSha256: bundle.zipSha256 });
|
|
306
|
+
await ensureNodeStates(input.repository, stored.value.run_id, definition, at, uuid);
|
|
307
|
+
return { run: stored, bundleBytes: persistedBytes };
|
|
308
|
+
}
|
|
309
|
+
function definitionFromBundle(bundleBytes, run) {
|
|
310
|
+
const networkBytes = readStoreZip(bundleBytes).find(({ name }) => name === "network.yaml")?.data;
|
|
311
|
+
if (networkBytes === undefined ||
|
|
312
|
+
sha256Hex(networkBytes) !== run.definition_sha256) {
|
|
313
|
+
throw new EnsureRunError("RUN_SNAPSHOT_MISMATCH", "stored network definition does not match the Run definition SHA-256");
|
|
314
|
+
}
|
|
315
|
+
const loaded = loadNetworkDefinitionSource(networkBytes.toString("utf8"));
|
|
316
|
+
if (loaded.definition === undefined || loaded.errors.length > 0) {
|
|
317
|
+
throw new EnsureRunError("RUN_SNAPSHOT_MISMATCH", `stored network definition is invalid: ${formatValidationErrors(loaded.errors)}`);
|
|
318
|
+
}
|
|
319
|
+
if (loaded.definition.network_id !== run.network_id ||
|
|
320
|
+
loaded.definition.schema_version !== run.definition_schema_version) {
|
|
321
|
+
throw new EnsureRunError("RUN_SNAPSHOT_MISMATCH", "stored network definition identity does not match the Run");
|
|
322
|
+
}
|
|
323
|
+
return loaded.definition;
|
|
324
|
+
}
|
|
325
|
+
async function ensureNodeStates(repository, runId, definition, at, uuid) {
|
|
326
|
+
const existing = await repository.getNodeStates(runId);
|
|
327
|
+
const nodesById = new Map(definition.nodes.map((node) => [node.id, node]));
|
|
328
|
+
for (const state of existing) {
|
|
329
|
+
const node = nodesById.get(state.value.node_id);
|
|
330
|
+
if (node === undefined ||
|
|
331
|
+
state.value.job_id !== node.job_id ||
|
|
332
|
+
state.value.idempotent !== node.idempotent ||
|
|
333
|
+
state.value.trigger_rule !== node.trigger_rule) {
|
|
334
|
+
throw new EnsureRunError("RUN_SNAPSHOT_MISMATCH", `Node State '${state.value.node_id}' does not match the stored bundle`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const existingIds = new Set(existing.map(({ value }) => value.node_id));
|
|
338
|
+
for (const node of definition.nodes) {
|
|
339
|
+
if (existingIds.has(node.id))
|
|
340
|
+
continue;
|
|
341
|
+
await repository.upsertNodeState({
|
|
342
|
+
value: initialNodeState(runId, node, at, uuid),
|
|
343
|
+
expected_revision: null,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function initialNodeState(runId, node, at, uuid) {
|
|
348
|
+
return {
|
|
349
|
+
node_state_id: `nodestate_${uuid()}`,
|
|
350
|
+
node_state_key: nodeStateKey(runId, node.id),
|
|
351
|
+
run_id: runId,
|
|
352
|
+
node_id: node.id,
|
|
353
|
+
job_id: node.job_id,
|
|
354
|
+
status: "WAITING",
|
|
355
|
+
latest_attempt_no: 0,
|
|
356
|
+
active_attempt_id: null,
|
|
357
|
+
revision: 1,
|
|
358
|
+
idempotent: node.idempotent,
|
|
359
|
+
trigger_rule: "all_success",
|
|
360
|
+
blocked_by: [],
|
|
361
|
+
status_reason: null,
|
|
362
|
+
started_at: null,
|
|
363
|
+
finished_at: null,
|
|
364
|
+
updated_at: at,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
async function createInvocation(input, run, mode, now, uuid, selectedNodeIds) {
|
|
368
|
+
const states = selectedNodeIds === undefined
|
|
369
|
+
? await input.repository.getNodeStates(run.value.run_id)
|
|
370
|
+
: [];
|
|
371
|
+
const preserved = states
|
|
372
|
+
.filter(({ value }) => value.status === "SUCCESS")
|
|
373
|
+
.map(({ value }) => value.node_id)
|
|
374
|
+
.sort();
|
|
375
|
+
const blocked = states
|
|
376
|
+
.filter(({ value }) => value.status === "UNKNOWN" ||
|
|
377
|
+
((value.status === "FAILED" || value.status === "CANCELLED") &&
|
|
378
|
+
!value.idempotent))
|
|
379
|
+
.map(({ value }) => value.node_id)
|
|
380
|
+
.sort();
|
|
381
|
+
const blockedSet = new Set(blocked);
|
|
382
|
+
const selected = selectedNodeIds === undefined
|
|
383
|
+
? states
|
|
384
|
+
.filter(({ value }) => value.status !== "SUCCESS" && !blockedSet.has(value.node_id))
|
|
385
|
+
.map(({ value }) => value.node_id)
|
|
386
|
+
.sort()
|
|
387
|
+
: [...selectedNodeIds];
|
|
388
|
+
return input.repository.createInvocation({
|
|
389
|
+
invocation_id: input.invocationId ?? `invoke_${uuid()}`,
|
|
390
|
+
run_id: run.value.run_id,
|
|
391
|
+
mode,
|
|
392
|
+
requested_by: input.requestedBy,
|
|
393
|
+
host: input.host,
|
|
394
|
+
started_at: now().toISOString(),
|
|
395
|
+
finished_at: null,
|
|
396
|
+
status: "RUNNING",
|
|
397
|
+
result_code: "PENDING",
|
|
398
|
+
selected_node_ids: selected,
|
|
399
|
+
preserved_node_ids: preserved,
|
|
400
|
+
blocked_node_ids: blocked,
|
|
401
|
+
reason: mode === "NEW"
|
|
402
|
+
? "new business run"
|
|
403
|
+
: mode === "RERUN_FROM"
|
|
404
|
+
? `rerun from ${input.rerunFrom}`
|
|
405
|
+
: "resume incomplete run",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
async function prepareRerunFrom(repository, run, definition, nodeId, now, beforeTransitions) {
|
|
409
|
+
let selected;
|
|
410
|
+
try {
|
|
411
|
+
selected = descendantsIncludingSelf(definition, nodeId);
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (error instanceof DescendantsError) {
|
|
415
|
+
throw new EnsureRunError(error.code === "DESCENDANTS_NODE_NOT_FOUND"
|
|
416
|
+
? "RERUN_FROM_NODE_NOT_FOUND"
|
|
417
|
+
: "RERUN_FROM_DAG_INVALID", error.message, [], { cause: error });
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
const states = await repository.getNodeStates(run.value.run_id);
|
|
422
|
+
const byId = new Map(states.map((state) => [state.value.node_id, state]));
|
|
423
|
+
const selectedStates = selected.map((id) => {
|
|
424
|
+
const state = byId.get(id);
|
|
425
|
+
if (state === undefined) {
|
|
426
|
+
throw new EnsureRunError("RERUN_FROM_DAG_INVALID", `Node State '${id}' is missing for the snapshot DAG`);
|
|
427
|
+
}
|
|
428
|
+
return state;
|
|
429
|
+
});
|
|
430
|
+
// All safety checks complete before the first rerun state transition.
|
|
431
|
+
if (run.value.status === "SUCCESS") {
|
|
432
|
+
throw new EnsureRunError("RERUN_FROM_SUCCESS_RUN", `run '${run.value.run_id}' is terminal SUCCESS and cannot be reopened; create a new correction business key`);
|
|
433
|
+
}
|
|
434
|
+
const unknown = selectedStates
|
|
435
|
+
.filter(({ value }) => value.status === "UNKNOWN")
|
|
436
|
+
.map(({ value }) => value.node_id);
|
|
437
|
+
if (unknown.length > 0) {
|
|
438
|
+
throw new EnsureRunError("RERUN_FROM_UNKNOWN_STATE", `rerun target contains unresolved UNKNOWN Node State(s): ${unknown.join(", ")}`);
|
|
439
|
+
}
|
|
440
|
+
const nonIdempotent = selectedStates
|
|
441
|
+
.filter(({ value }) => !value.idempotent && value.latest_attempt_no > 0)
|
|
442
|
+
.map(({ value }) => value.node_id);
|
|
443
|
+
if (nonIdempotent.length > 0) {
|
|
444
|
+
throw new EnsureRunError("RERUN_FROM_NON_IDEMPOTENT", `rerun target contains executed idempotent=false node(s): ${nonIdempotent.join(", ")}`);
|
|
445
|
+
}
|
|
446
|
+
await beforeTransitions?.(selected);
|
|
447
|
+
const at = now().toISOString();
|
|
448
|
+
for (const state of selectedStates) {
|
|
449
|
+
await repository.upsertNodeState({
|
|
450
|
+
expected_revision: state.revision,
|
|
451
|
+
value: {
|
|
452
|
+
...state.value,
|
|
453
|
+
status: "WAITING",
|
|
454
|
+
active_attempt_id: null,
|
|
455
|
+
blocked_by: [],
|
|
456
|
+
status_reason: null,
|
|
457
|
+
finished_at: null,
|
|
458
|
+
updated_at: at,
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return selected;
|
|
463
|
+
}
|
|
464
|
+
function assertInputRetention(run, definition, retentionDays, now) {
|
|
465
|
+
if (!definition.nodes.some((node) => Object.keys(node.inputs ?? {}).length > 0))
|
|
466
|
+
return;
|
|
467
|
+
if (!Number.isSafeInteger(retentionDays) || retentionDays <= 0)
|
|
468
|
+
throw new EnsureRunError("INPUT_RETENTION_EXPIRED", "input retention period configuration is invalid; create a new Run only after correcting it");
|
|
469
|
+
const createdAt = Date.parse(run.created_at);
|
|
470
|
+
if (!Number.isFinite(createdAt))
|
|
471
|
+
throw new EnsureRunError("RUN_SNAPSHOT_MISMATCH", "stored Run creation time is invalid");
|
|
472
|
+
const expiresAt = createdAt + retentionDays * 86_400_000;
|
|
473
|
+
// kintone DATETIME values may lose seconds; allow the full final minute.
|
|
474
|
+
if (now.getTime() <= expiresAt + 59_999)
|
|
475
|
+
return;
|
|
476
|
+
throw new EnsureRunError("INPUT_RETENTION_EXPIRED", `input retention period (${retentionDays} days from Run creation) has expired; create a new Run with a correction business key`);
|
|
477
|
+
}
|
|
478
|
+
async function verifyExistingInputBaselines(repository, run, definition, targetNodeIds, ioRoot, profile) {
|
|
479
|
+
if (targetNodeIds.size === 0 || ioRoot === undefined)
|
|
480
|
+
return;
|
|
481
|
+
const attempts = await repository.getAttempts(run.run_id);
|
|
482
|
+
for (const node of definition.nodes) {
|
|
483
|
+
const patterns = node.inputs ?? {};
|
|
484
|
+
if (!targetNodeIds.has(node.id) || Object.keys(patterns).length === 0)
|
|
485
|
+
continue;
|
|
486
|
+
const previous = latestStoredBaseline(attempts, node.id);
|
|
487
|
+
if (previous === null)
|
|
488
|
+
continue;
|
|
489
|
+
let current;
|
|
490
|
+
try {
|
|
491
|
+
current = await resolveNodeInputs({
|
|
492
|
+
ioRoot,
|
|
493
|
+
patterns,
|
|
494
|
+
businessKey: run.business_key,
|
|
495
|
+
profile,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
if (error instanceof InputPathError)
|
|
500
|
+
continue;
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
if (!inputBaselinesEqual(previous, current)) {
|
|
504
|
+
throw new EnsureRunError("INPUT_FILE_MUTATED", `input file changed for node '${node.id}'; restore the original file or create a new Run with a correction business key`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function latestStoredBaseline(attempts, nodeId) {
|
|
509
|
+
try {
|
|
510
|
+
for (const attempt of attempts
|
|
511
|
+
.filter(({ value }) => value.node_id === nodeId)
|
|
512
|
+
.sort((left, right) => right.value.attempt_no - left.value.attempt_no)) {
|
|
513
|
+
const baseline = parseInputBaseline(attempt.value.error_message);
|
|
514
|
+
if (baseline !== null)
|
|
515
|
+
return baseline;
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
throw new EnsureRunError("INPUT_FILE_MUTATED", `stored input baseline for node '${nodeId}' is invalid; create a new Run with a correction business key`, [], { cause: error });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function resolveEnsureBusinessKey(input, networkId, policy, now) {
|
|
524
|
+
if (input.resumeRunId !== undefined)
|
|
525
|
+
return "";
|
|
526
|
+
const manual = input.resume !== true &&
|
|
527
|
+
input.scheduledFor === undefined &&
|
|
528
|
+
input.businessKey === undefined
|
|
529
|
+
? `${networkId}@manual-${manualTimestamp(now())}`
|
|
530
|
+
: undefined;
|
|
531
|
+
const selectedBusinessKey = input.businessKey ?? manual;
|
|
532
|
+
const resolved = resolveBusinessKey({
|
|
533
|
+
networkId,
|
|
534
|
+
policy,
|
|
535
|
+
...(input.scheduledFor === undefined
|
|
536
|
+
? {}
|
|
537
|
+
: { scheduledFor: input.scheduledFor }),
|
|
538
|
+
...(selectedBusinessKey === undefined
|
|
539
|
+
? {}
|
|
540
|
+
: { businessKey: selectedBusinessKey }),
|
|
541
|
+
});
|
|
542
|
+
if (resolved.businessKey === undefined || resolved.errors.length > 0) {
|
|
543
|
+
throw new EnsureRunError("BUSINESS_KEY_INVALID", formatValidationErrors(resolved.errors));
|
|
544
|
+
}
|
|
545
|
+
return resolved.businessKey;
|
|
546
|
+
}
|
|
547
|
+
function manualTimestamp(value) {
|
|
548
|
+
return value
|
|
549
|
+
.toISOString()
|
|
550
|
+
.replaceAll("-", "")
|
|
551
|
+
.replaceAll(":", "")
|
|
552
|
+
.replace(".", "");
|
|
553
|
+
}
|
|
554
|
+
function assertRunIdentity(run, profile, networkId) {
|
|
555
|
+
const mismatches = [];
|
|
556
|
+
if (run.network_id !== networkId)
|
|
557
|
+
mismatches.push("network_id");
|
|
558
|
+
if (run.resolved_profile_snapshot.profile !== profile)
|
|
559
|
+
mismatches.push("profile");
|
|
560
|
+
if (mismatches.length > 0) {
|
|
561
|
+
throw new EnsureRunError("RUN_ID_MISMATCH", `specified run does not match ${mismatches.join(", ")}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function assertRunResumable(run) {
|
|
565
|
+
if (!run.resume_allowed || run.lifecycle_status === "ARCHIVED") {
|
|
566
|
+
throw new EnsureRunError("RUN_NOT_RESUMABLE", `run '${run.run_id}' is not resumable (lifecycle_status=${run.lifecycle_status}, resume_allowed=${String(run.resume_allowed)})`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function assertDescriptionProfile(description, profile) {
|
|
570
|
+
if (description.profile !== profile) {
|
|
571
|
+
throw new EnsureRunError("PROFILE_DESCRIPTION_INVALID", `describe-profile returned '${description.profile}', expected '${profile}'`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function assertStoredProfile(description, run) {
|
|
575
|
+
const snapshot = run.resolved_profile_snapshot;
|
|
576
|
+
assertProfileSnapshot(description, {
|
|
577
|
+
canonicalJsonSha256: run.resolved_profile_sha256,
|
|
578
|
+
baseUrl: snapshot.base_url,
|
|
579
|
+
guestSpaceId: snapshot.guest_space_id,
|
|
580
|
+
apps: snapshot.apps,
|
|
581
|
+
timezone: snapshot.timezone,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function persistenceProfileSnapshot(description) {
|
|
585
|
+
const maxApiCalls = description.limits.maxApiCalls;
|
|
586
|
+
const maxReadRows = description.limits.maxReadRows;
|
|
587
|
+
const batchTimeoutSec = description.limits.batchTimeoutSec;
|
|
588
|
+
if (description.timezone === null ||
|
|
589
|
+
!isFiniteNumberOrNull(maxApiCalls) ||
|
|
590
|
+
!isFiniteNumberOrNull(maxReadRows) ||
|
|
591
|
+
!isFiniteNumberOrNull(batchTimeoutSec)) {
|
|
592
|
+
throw new EnsureRunError("PROFILE_DESCRIPTION_INVALID", "describe-profile is missing timezone or required limits");
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
profile: description.profile,
|
|
596
|
+
base_url: description.baseUrl,
|
|
597
|
+
guest_space_id: description.guestSpaceId,
|
|
598
|
+
timezone: description.timezone,
|
|
599
|
+
apps: { ...description.apps },
|
|
600
|
+
limits: {
|
|
601
|
+
max_api_calls: maxApiCalls,
|
|
602
|
+
max_read_rows: maxReadRows,
|
|
603
|
+
batch_timeout_sec: batchTimeoutSec,
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
function isFiniteNumberOrNull(value) {
|
|
608
|
+
return (value === null || (typeof value === "number" && Number.isFinite(value)));
|
|
609
|
+
}
|
|
610
|
+
function formatValidationErrors(errors) {
|
|
611
|
+
return errors
|
|
612
|
+
.map((error) => `[${error.code}] ${error.path}: ${error.message}`)
|
|
613
|
+
.join("; ");
|
|
614
|
+
}
|
|
615
|
+
function errorCode(error) {
|
|
616
|
+
if (typeof error === "object" &&
|
|
617
|
+
error !== null &&
|
|
618
|
+
"code" in error &&
|
|
619
|
+
typeof error.code === "string") {
|
|
620
|
+
return error.code;
|
|
621
|
+
}
|
|
622
|
+
return "ENSURE_RUN_FAILED";
|
|
623
|
+
}
|
|
624
|
+
function normalizeEnsureError(error) {
|
|
625
|
+
if (error instanceof Error)
|
|
626
|
+
return error;
|
|
627
|
+
return new EnsureRunError("ENSURE_RUN_FAILED", "ensure-run failed with a non-Error value", [], { cause: error });
|
|
628
|
+
}
|
|
629
|
+
export function profileDescriptionSha256(description) {
|
|
630
|
+
return canonicalJsonSha256(description);
|
|
631
|
+
}
|
|
632
|
+
//# sourceMappingURL=ensure-run.js.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JobLockRecoveryOperationAudit, LockRecoveryResult } from "../domain/persistence-model.js";
|
|
2
|
+
import type { PersistenceRepository } from "../persistence/repository.js";
|
|
3
|
+
export type JobLockAuditRepository = Pick<PersistenceRepository, "getRun" | "getNodeStates" | "appendOperationAudit">;
|
|
4
|
+
export interface RecordJobUnlockInput {
|
|
5
|
+
repository: JobLockAuditRepository;
|
|
6
|
+
runId: string;
|
|
7
|
+
nodeId: string;
|
|
8
|
+
result: unknown;
|
|
9
|
+
reason: string;
|
|
10
|
+
evidenceRef: string;
|
|
11
|
+
servicePrincipal: string;
|
|
12
|
+
requestedBy: string;
|
|
13
|
+
stopConfirmedBy: string;
|
|
14
|
+
now?: () => Date;
|
|
15
|
+
uuid?: () => string;
|
|
16
|
+
}
|
|
17
|
+
export declare class JobLockAuditError extends Error {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
constructor(code: string, message: string);
|
|
20
|
+
}
|
|
21
|
+
/** Records the kSQL-Flow result only. This module has no Job lock mutation dependency. */
|
|
22
|
+
export declare function recordJobUnlock(input: RecordJobUnlockInput): Promise<JobLockRecoveryOperationAudit>;
|
|
23
|
+
export declare function parseLockRecoveryResult(value: unknown): LockRecoveryResult;
|
|
24
|
+
//# sourceMappingURL=job-lock-audit.d.ts.map
|