@tiangong-lca/cli 0.0.29 → 0.0.30
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 +7 -2
- package/dist/src/cli.js +9 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/src/lib/dataset-command.js +11 -0
- package/dist/src/lib/dataset-command.js.map +1 -1
- package/dist/src/lib/dataset-save-draft-run.js +667 -0
- package/dist/src/lib/dataset-save-draft-run.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { closeSync, chmodSync, fsyncSync, mkdirSync, openSync, writeFileSync } from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import * as tidasSdk from '@tiangong-lca/tidas-sdk';
|
|
3
4
|
import { writeJsonArtifact, writeJsonLinesArtifact } from './artifacts.js';
|
|
@@ -11,6 +12,8 @@ import { buildDatasetCommandTransport } from './dataset-command.js';
|
|
|
11
12
|
import { createSupabaseDataClient, requireSupabaseRestRuntime, runSupabaseArrayQuery, } from './supabase-client.js';
|
|
12
13
|
import { createSupabaseDataRuntime } from './supabase-session.js';
|
|
13
14
|
import { collectRemoteReferences, lookupRemoteDataset, } from './dataset-remote-verify.js';
|
|
15
|
+
import { readJsonFile, readJsonLinesIfPresent, sha256Json, stableJsonText, } from './dataset-maintenance-contract.js';
|
|
16
|
+
import { resolveFlowIdentityApprovalClaimRoot } from './dataset-maintenance-flow-identity-approval-claim.js';
|
|
14
17
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
15
18
|
function normalizeValidationIssue(issue) {
|
|
16
19
|
return {
|
|
@@ -67,6 +70,279 @@ const REFERENCE_ONLY_SAVE_DRAFT_TYPES = new Set([
|
|
|
67
70
|
'unitgroup',
|
|
68
71
|
'flowproperty',
|
|
69
72
|
]);
|
|
73
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
|
74
|
+
function executionContractError(message) {
|
|
75
|
+
throw new CliError(message, {
|
|
76
|
+
code: 'DATASET_SAVE_DRAFT_EXECUTION_CONTRACT_INVALID',
|
|
77
|
+
exitCode: 2,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function requireExecutionToken(value, label) {
|
|
81
|
+
const normalized = trimToken(value);
|
|
82
|
+
return normalized ?? executionContractError(`${label} must be a non-empty string.`);
|
|
83
|
+
}
|
|
84
|
+
function requireExecutionSha(value, label) {
|
|
85
|
+
const normalized = requireExecutionToken(value, label);
|
|
86
|
+
return SHA256_PATTERN.test(normalized)
|
|
87
|
+
? normalized
|
|
88
|
+
: executionContractError(`${label} must be a lowercase SHA-256 digest.`);
|
|
89
|
+
}
|
|
90
|
+
function parseExecutionContract(value) {
|
|
91
|
+
if (!isRecord(value)) {
|
|
92
|
+
executionContractError('Execution contract must be a JSON object.');
|
|
93
|
+
}
|
|
94
|
+
if (value.schema_version !== 'dataset-save-draft-execution-contract.v1' ||
|
|
95
|
+
value.target_mode !== 'owner_draft' ||
|
|
96
|
+
!isRecord(value.owner) ||
|
|
97
|
+
value.owner.state_code !== 0 ||
|
|
98
|
+
!Array.isArray(value.actions) ||
|
|
99
|
+
value.actions.length === 0) {
|
|
100
|
+
executionContractError('Execution contract header is invalid.');
|
|
101
|
+
}
|
|
102
|
+
const executionId = requireExecutionToken(value.execution_id, 'execution_id');
|
|
103
|
+
const projectRef = requireExecutionToken(value.project_ref, 'project_ref');
|
|
104
|
+
const ownerUserId = requireExecutionToken(value.owner.user_id, 'owner.user_id');
|
|
105
|
+
const ownerEmail = requireExecutionToken(value.owner.email, 'owner.email').toLowerCase();
|
|
106
|
+
const actions = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const [index, rawAction] of value.actions.entries()) {
|
|
109
|
+
if (!isRecord(rawAction) || !Array.isArray(rawAction.dependency_action_ids)) {
|
|
110
|
+
executionContractError(`actions[${index}] is invalid.`);
|
|
111
|
+
}
|
|
112
|
+
const actionId = requireExecutionToken(rawAction.action_id, `actions[${index}].action_id`);
|
|
113
|
+
if (seen.has(actionId)) {
|
|
114
|
+
executionContractError(`Duplicate action_id: ${actionId}`);
|
|
115
|
+
}
|
|
116
|
+
const table = requireExecutionToken(rawAction.table, `actions[${index}].table`);
|
|
117
|
+
if (!Object.values(DATASET_CONFIGS).some((config) => config.table === table)) {
|
|
118
|
+
executionContractError(`actions[${index}].table is unsupported.`);
|
|
119
|
+
}
|
|
120
|
+
const expectedOperation = rawAction.expected_operation;
|
|
121
|
+
if (expectedOperation !== 'insert' && expectedOperation !== 'save_draft') {
|
|
122
|
+
executionContractError(`actions[${index}].expected_operation is invalid.`);
|
|
123
|
+
}
|
|
124
|
+
const beforeSha = rawAction.before_sha256 === null
|
|
125
|
+
? null
|
|
126
|
+
: requireExecutionSha(rawAction.before_sha256, `actions[${index}].before_sha256`);
|
|
127
|
+
if ((expectedOperation === 'insert' && beforeSha !== null) ||
|
|
128
|
+
(expectedOperation === 'save_draft' && beforeSha === null)) {
|
|
129
|
+
executionContractError(`actions[${index}].before_sha256 contradicts expected_operation.`);
|
|
130
|
+
}
|
|
131
|
+
const dependencies = rawAction.dependency_action_ids.map((dependency, dependencyIndex) => requireExecutionToken(dependency, `actions[${index}].dependency_action_ids[${dependencyIndex}]`));
|
|
132
|
+
if (new Set(dependencies).size !== dependencies.length ||
|
|
133
|
+
dependencies.some((id) => !seen.has(id))) {
|
|
134
|
+
executionContractError(`actions[${index}].dependency_action_ids must be unique earlier actions.`);
|
|
135
|
+
}
|
|
136
|
+
actions.push({
|
|
137
|
+
action_id: actionId,
|
|
138
|
+
desired_sha256: requireExecutionSha(rawAction.desired_sha256, `actions[${index}].desired_sha256`),
|
|
139
|
+
expected_operation: expectedOperation,
|
|
140
|
+
table: table,
|
|
141
|
+
id: requireExecutionToken(rawAction.id, `actions[${index}].id`),
|
|
142
|
+
version: requireExecutionToken(rawAction.version, `actions[${index}].version`),
|
|
143
|
+
before_sha256: beforeSha,
|
|
144
|
+
dependency_action_ids: dependencies,
|
|
145
|
+
});
|
|
146
|
+
seen.add(actionId);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
schema_version: 'dataset-save-draft-execution-contract.v1',
|
|
150
|
+
execution_id: executionId,
|
|
151
|
+
project_ref: projectRef,
|
|
152
|
+
target_mode: 'owner_draft',
|
|
153
|
+
owner: { user_id: ownerUserId, email: ownerEmail, state_code: 0 },
|
|
154
|
+
actions,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function bindExecutionContractRows(contract, rows) {
|
|
158
|
+
if (contract.actions.length !== rows.length) {
|
|
159
|
+
executionContractError('Execution contract action count does not match selected rows.');
|
|
160
|
+
}
|
|
161
|
+
contract.actions.forEach((action, index) => {
|
|
162
|
+
const row = rows[index];
|
|
163
|
+
const payloadIdentity = row?.config
|
|
164
|
+
? extractIdentity(row.payload, {}, row.config)
|
|
165
|
+
: { id: null, version: null };
|
|
166
|
+
if (!row ||
|
|
167
|
+
row.config?.table !== action.table ||
|
|
168
|
+
row.id !== action.id ||
|
|
169
|
+
row.version !== action.version ||
|
|
170
|
+
payloadIdentity.id !== action.id ||
|
|
171
|
+
payloadIdentity.version !== action.version ||
|
|
172
|
+
sha256Json(row.payload) !== action.desired_sha256) {
|
|
173
|
+
executionContractError(`Execution contract action ${action.action_id} does not bind row ${index}.`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function executionActionBindingSha256(action) {
|
|
178
|
+
return sha256Json({
|
|
179
|
+
schema_version: 'dataset-save-draft-action-binding.v1',
|
|
180
|
+
action_id: action.action_id,
|
|
181
|
+
desired_sha256: action.desired_sha256,
|
|
182
|
+
expected_operation: action.expected_operation,
|
|
183
|
+
table: action.table,
|
|
184
|
+
id: action.id,
|
|
185
|
+
version: action.version,
|
|
186
|
+
before_sha256: action.before_sha256,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function executionLedgerRoot(env, contract) {
|
|
190
|
+
const ownerScopeSha256 = sha256Json({
|
|
191
|
+
schema_version: 'dataset-save-draft-owner-scope.v1',
|
|
192
|
+
project_ref: contract.project_ref,
|
|
193
|
+
owner: contract.owner,
|
|
194
|
+
});
|
|
195
|
+
return path.join(resolveFlowIdentityApprovalClaimRoot({ env }), 'execution-ledgers', 'dataset-save-draft', 'v1', ownerScopeSha256);
|
|
196
|
+
}
|
|
197
|
+
function executionLedgerPath(ledgerRoot, action) {
|
|
198
|
+
const actionIdentitySha256 = sha256Json({
|
|
199
|
+
schema_version: 'dataset-save-draft-action-identity.v1',
|
|
200
|
+
action_id: action.action_id,
|
|
201
|
+
desired_sha256: action.desired_sha256,
|
|
202
|
+
});
|
|
203
|
+
return path.join(path.resolve(ledgerRoot), `${actionIdentitySha256}.events.jsonl`);
|
|
204
|
+
}
|
|
205
|
+
function eventWithoutSha(event) {
|
|
206
|
+
const core = { ...event };
|
|
207
|
+
delete core.event_sha256;
|
|
208
|
+
return core;
|
|
209
|
+
}
|
|
210
|
+
function parseLedgerEvent(value, index) {
|
|
211
|
+
if (!isRecord(value)) {
|
|
212
|
+
executionContractError(`Execution ledger event ${index} is not an object.`);
|
|
213
|
+
}
|
|
214
|
+
const event = value;
|
|
215
|
+
if (event.schema_version !== 'dataset-save-draft-execution-event.v1' ||
|
|
216
|
+
event.sequence !== index + 1 ||
|
|
217
|
+
!SHA256_PATTERN.test(event.contract_sha256) ||
|
|
218
|
+
!trimToken(event.action_id) ||
|
|
219
|
+
!SHA256_PATTERN.test(event.desired_sha256) ||
|
|
220
|
+
!SHA256_PATTERN.test(event.action_binding_sha256) ||
|
|
221
|
+
!['attempt_emitted', 'outcome'].includes(event.event_type) ||
|
|
222
|
+
!['insert', 'save_draft'].includes(event.operation) ||
|
|
223
|
+
!['executed', 'unknown', null].includes(event.outcome) ||
|
|
224
|
+
typeof event.recovered !== 'boolean' ||
|
|
225
|
+
!trimToken(event.recorded_at_utc) ||
|
|
226
|
+
!(event.previous_event_sha256 === null || SHA256_PATTERN.test(event.previous_event_sha256)) ||
|
|
227
|
+
!SHA256_PATTERN.test(event.event_sha256)) {
|
|
228
|
+
executionContractError(`Execution ledger event ${index} has an invalid shape.`);
|
|
229
|
+
}
|
|
230
|
+
if ((event.event_type === 'attempt_emitted' && event.outcome !== null) ||
|
|
231
|
+
(event.event_type === 'outcome' && event.outcome === null)) {
|
|
232
|
+
executionContractError(`Execution ledger event ${index} has an invalid outcome.`);
|
|
233
|
+
}
|
|
234
|
+
return event;
|
|
235
|
+
}
|
|
236
|
+
function loadExecutionLedger(ledgerRoot, contract) {
|
|
237
|
+
const events = new Map();
|
|
238
|
+
const attempts = new Map();
|
|
239
|
+
const outcomes = new Map();
|
|
240
|
+
for (const action of contract.actions) {
|
|
241
|
+
const actionEvents = [];
|
|
242
|
+
const rawEvents = readJsonLinesIfPresent(executionLedgerPath(ledgerRoot, action));
|
|
243
|
+
for (const [index, value] of rawEvents.entries()) {
|
|
244
|
+
const event = parseLedgerEvent(value, index);
|
|
245
|
+
const previous = actionEvents.at(-1) ?? null;
|
|
246
|
+
if (event.action_id !== action.action_id ||
|
|
247
|
+
event.desired_sha256 !== action.desired_sha256 ||
|
|
248
|
+
event.action_binding_sha256 !== executionActionBindingSha256(action) ||
|
|
249
|
+
event.operation !== action.expected_operation ||
|
|
250
|
+
event.previous_event_sha256 !== (previous?.event_sha256 ?? null) ||
|
|
251
|
+
event.event_sha256 !== sha256Json(eventWithoutSha(event))) {
|
|
252
|
+
executionContractError(`Execution ledger event ${index} failed its hash or action binding for ${action.action_id}.`);
|
|
253
|
+
}
|
|
254
|
+
if (event.event_type === 'attempt_emitted') {
|
|
255
|
+
if (attempts.has(event.action_id) || outcomes.has(event.action_id)) {
|
|
256
|
+
executionContractError(`Execution ledger repeats attempt for ${event.action_id}.`);
|
|
257
|
+
}
|
|
258
|
+
attempts.set(event.action_id, event);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
if (!attempts.has(event.action_id) || outcomes.has(event.action_id)) {
|
|
262
|
+
executionContractError(`Execution ledger outcome ordering is invalid for ${event.action_id}.`);
|
|
263
|
+
}
|
|
264
|
+
outcomes.set(event.action_id, event);
|
|
265
|
+
}
|
|
266
|
+
actionEvents.push(event);
|
|
267
|
+
}
|
|
268
|
+
events.set(action.action_id, actionEvents);
|
|
269
|
+
}
|
|
270
|
+
return { events, attempts, outcomes };
|
|
271
|
+
}
|
|
272
|
+
function appendExecutionEvent(options) {
|
|
273
|
+
const actionEvents = options.ledger.events.get(options.action.action_id);
|
|
274
|
+
const core = {
|
|
275
|
+
schema_version: 'dataset-save-draft-execution-event.v1',
|
|
276
|
+
sequence: actionEvents.length + 1,
|
|
277
|
+
contract_sha256: options.contractSha256,
|
|
278
|
+
action_id: options.action.action_id,
|
|
279
|
+
desired_sha256: options.action.desired_sha256,
|
|
280
|
+
action_binding_sha256: executionActionBindingSha256(options.action),
|
|
281
|
+
event_type: options.eventType,
|
|
282
|
+
operation: options.action.expected_operation,
|
|
283
|
+
outcome: options.outcome,
|
|
284
|
+
recovered: options.recovered,
|
|
285
|
+
recorded_at_utc: options.recordedAtUtc,
|
|
286
|
+
previous_event_sha256: actionEvents.at(-1)?.event_sha256 ?? null,
|
|
287
|
+
};
|
|
288
|
+
const event = { ...core, event_sha256: sha256Json(core) };
|
|
289
|
+
const ledgerPath = executionLedgerPath(options.ledgerRoot, options.action);
|
|
290
|
+
mkdirSync(path.dirname(ledgerPath), { recursive: true, mode: 0o700 });
|
|
291
|
+
chmodSync(path.dirname(ledgerPath), 0o700);
|
|
292
|
+
if (event.event_type === 'attempt_emitted') {
|
|
293
|
+
const createDescriptor = openSync(ledgerPath, 'wx', 0o600);
|
|
294
|
+
try {
|
|
295
|
+
writeFileSync(createDescriptor, `${stableJsonText(event)}\n`, 'utf8');
|
|
296
|
+
fsyncSync(createDescriptor);
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
closeSync(createDescriptor);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const appendDescriptor = openSync(ledgerPath, 'a', 0o600);
|
|
304
|
+
try {
|
|
305
|
+
writeFileSync(appendDescriptor, `${stableJsonText(event)}\n`, 'utf8');
|
|
306
|
+
fsyncSync(appendDescriptor);
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
closeSync(appendDescriptor);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
chmodSync(ledgerPath, 0o600);
|
|
313
|
+
actionEvents.push(event);
|
|
314
|
+
options.ledger.events.set(event.action_id, actionEvents);
|
|
315
|
+
if (event.event_type === 'attempt_emitted') {
|
|
316
|
+
options.ledger.attempts.set(event.action_id, event);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
options.ledger.outcomes.set(event.action_id, event);
|
|
320
|
+
}
|
|
321
|
+
return event;
|
|
322
|
+
}
|
|
323
|
+
function projectRefFromApiBaseUrl(apiBaseUrl) {
|
|
324
|
+
return new URL(apiBaseUrl).hostname.split('.')[0];
|
|
325
|
+
}
|
|
326
|
+
function decodeExecutionActor(accessToken) {
|
|
327
|
+
const payload = accessToken.split('.')[1];
|
|
328
|
+
if (!payload) {
|
|
329
|
+
executionContractError('Owner-session access token is not a JWT.');
|
|
330
|
+
}
|
|
331
|
+
let decoded;
|
|
332
|
+
try {
|
|
333
|
+
decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
executionContractError('Owner-session access token JWT payload is invalid.');
|
|
337
|
+
}
|
|
338
|
+
if (!isRecord(decoded)) {
|
|
339
|
+
executionContractError('Owner-session access token JWT payload is not an object.');
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
user_id: requireExecutionToken(decoded.sub, 'owner-session sub'),
|
|
343
|
+
email: requireExecutionToken(decoded.email, 'owner-session email').toLowerCase(),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
70
346
|
function isRecord(value) {
|
|
71
347
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
72
348
|
}
|
|
@@ -531,6 +807,355 @@ async function exactVisibleRows(options) {
|
|
|
531
807
|
.eq('version', options.version), url);
|
|
532
808
|
return parseVisibleRows(payload, url);
|
|
533
809
|
}
|
|
810
|
+
function parseExecutionRows(payload, url) {
|
|
811
|
+
const visible = parseVisibleRows(payload, url);
|
|
812
|
+
return visible.map((row, index) => {
|
|
813
|
+
const raw = payload[index];
|
|
814
|
+
const jsonOrdered = isRecord(raw) && isRecord(raw.json_ordered) ? raw.json_ordered : null;
|
|
815
|
+
return { ...row, json_ordered: jsonOrdered };
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
async function exactExecutionRows(options) {
|
|
819
|
+
const url = new URL(buildVisibleRowsUrl(options.restBaseUrl, options.table, options.id, options.version));
|
|
820
|
+
url.searchParams.set('select', 'id,version,user_id,state_code,json_ordered');
|
|
821
|
+
const payload = await runSupabaseArrayQuery(options.client
|
|
822
|
+
.from(options.table)
|
|
823
|
+
.select('id,version,user_id,state_code,json_ordered')
|
|
824
|
+
.eq('id', options.id)
|
|
825
|
+
.eq('version', options.version), url.toString());
|
|
826
|
+
return parseExecutionRows(payload, url.toString());
|
|
827
|
+
}
|
|
828
|
+
function exactDesiredReadback(options) {
|
|
829
|
+
const row = options.rows[0];
|
|
830
|
+
return Boolean(options.rows.length === 1 &&
|
|
831
|
+
row &&
|
|
832
|
+
row.id === options.action.id &&
|
|
833
|
+
row.version === options.action.version &&
|
|
834
|
+
row.user_id === options.contract.owner.user_id &&
|
|
835
|
+
row.state_code === 0 &&
|
|
836
|
+
row.json_ordered &&
|
|
837
|
+
sha256Json(row.json_ordered) === options.action.desired_sha256);
|
|
838
|
+
}
|
|
839
|
+
function contractRowReport(options) {
|
|
840
|
+
return {
|
|
841
|
+
index: options.row.index,
|
|
842
|
+
id: options.row.id,
|
|
843
|
+
version: options.row.version,
|
|
844
|
+
type: options.row.type,
|
|
845
|
+
table: options.action.table,
|
|
846
|
+
status: options.status,
|
|
847
|
+
operation: options.operation,
|
|
848
|
+
validation: options.row.validation,
|
|
849
|
+
action_id: options.action.action_id,
|
|
850
|
+
desired_sha256: options.action.desired_sha256,
|
|
851
|
+
attempt_consumed: options.attemptConsumed,
|
|
852
|
+
replayed: false,
|
|
853
|
+
readback: options.readback,
|
|
854
|
+
...(options.error ? { error: options.error } : {}),
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
async function finalizeAttemptedAction(options) {
|
|
858
|
+
const desiredExact = await readbackIsDesiredExact(options);
|
|
859
|
+
appendExecutionEvent({
|
|
860
|
+
ledgerRoot: options.ledgerRoot,
|
|
861
|
+
ledger: options.ledger,
|
|
862
|
+
contractSha256: options.contractSha256,
|
|
863
|
+
action: options.action,
|
|
864
|
+
eventType: 'outcome',
|
|
865
|
+
outcome: desiredExact ? 'executed' : 'unknown',
|
|
866
|
+
recovered: options.recovered,
|
|
867
|
+
recordedAtUtc: options.now(),
|
|
868
|
+
});
|
|
869
|
+
return contractRowReport({
|
|
870
|
+
row: options.row,
|
|
871
|
+
action: options.action,
|
|
872
|
+
status: desiredExact ? 'executed' : 'unknown',
|
|
873
|
+
operation: desiredExact && options.recovered
|
|
874
|
+
? 'recovered_exact_readback'
|
|
875
|
+
: options.action.expected_operation,
|
|
876
|
+
attemptConsumed: true,
|
|
877
|
+
readback: desiredExact ? 'desired_exact' : 'not_desired',
|
|
878
|
+
...(desiredExact
|
|
879
|
+
? {}
|
|
880
|
+
: {
|
|
881
|
+
error: {
|
|
882
|
+
message: 'A prior protected request was emitted without a terminal desired-exact readback; the action is UNKNOWN and will never be replayed.',
|
|
883
|
+
},
|
|
884
|
+
}),
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
async function readbackIsDesiredExact(options) {
|
|
888
|
+
try {
|
|
889
|
+
return exactDesiredReadback({
|
|
890
|
+
rows: await exactExecutionRows({
|
|
891
|
+
client: options.client,
|
|
892
|
+
restBaseUrl: options.restBaseUrl,
|
|
893
|
+
table: options.action.table,
|
|
894
|
+
id: options.action.id,
|
|
895
|
+
version: options.action.version,
|
|
896
|
+
}),
|
|
897
|
+
action: options.action,
|
|
898
|
+
contract: options.contract,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async function runExecutionContractBatch(options) {
|
|
906
|
+
const contractSha256 = sha256Json(options.contract);
|
|
907
|
+
const ledgerRoot = executionLedgerRoot(options.env, options.contract);
|
|
908
|
+
const ledger = loadExecutionLedger(ledgerRoot, options.contract);
|
|
909
|
+
const actor = decodeExecutionActor(options.commandTransport.accessToken);
|
|
910
|
+
if (projectRefFromApiBaseUrl(options.runtime.apiBaseUrl) !== options.contract.project_ref ||
|
|
911
|
+
actor.user_id !== options.contract.owner.user_id ||
|
|
912
|
+
actor.email !== options.contract.owner.email) {
|
|
913
|
+
executionContractError('Owner session or project does not match the execution contract.');
|
|
914
|
+
}
|
|
915
|
+
options.files.execution_ledger = ledgerRoot;
|
|
916
|
+
const reports = [];
|
|
917
|
+
const statuses = new Map();
|
|
918
|
+
const referenceOnlySupportCache = new Map();
|
|
919
|
+
for (const [index, action] of options.contract.actions.entries()) {
|
|
920
|
+
const row = options.preparedRows[index];
|
|
921
|
+
const preparedFailure = buildPreparedFailure(row, options.allowReferenceOnlySupport);
|
|
922
|
+
if (preparedFailure) {
|
|
923
|
+
const report = {
|
|
924
|
+
...preparedFailure,
|
|
925
|
+
action_id: action.action_id,
|
|
926
|
+
desired_sha256: action.desired_sha256,
|
|
927
|
+
attempt_consumed: false,
|
|
928
|
+
replayed: false,
|
|
929
|
+
readback: 'not_performed',
|
|
930
|
+
};
|
|
931
|
+
reports.push(report);
|
|
932
|
+
statuses.set(action.action_id, report.status);
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
const priorOutcome = ledger.outcomes.get(action.action_id);
|
|
936
|
+
if (priorOutcome) {
|
|
937
|
+
const desiredStillExact = priorOutcome.outcome === 'executed' &&
|
|
938
|
+
(await readbackIsDesiredExact({
|
|
939
|
+
client: options.dataClient.client,
|
|
940
|
+
restBaseUrl: options.dataClient.restBaseUrl,
|
|
941
|
+
action,
|
|
942
|
+
contract: options.contract,
|
|
943
|
+
}));
|
|
944
|
+
const status = desiredStillExact ? 'executed' : 'unknown';
|
|
945
|
+
const report = contractRowReport({
|
|
946
|
+
row,
|
|
947
|
+
action,
|
|
948
|
+
status,
|
|
949
|
+
operation: action.expected_operation,
|
|
950
|
+
attemptConsumed: true,
|
|
951
|
+
readback: desiredStillExact ? 'desired_exact' : 'not_desired',
|
|
952
|
+
...(status === 'unknown'
|
|
953
|
+
? {
|
|
954
|
+
error: {
|
|
955
|
+
message: priorOutcome.outcome === 'unknown'
|
|
956
|
+
? 'Terminal UNKNOWN action retained; replay is forbidden.'
|
|
957
|
+
: 'A terminal success no longer has exact desired owner readback; replay is forbidden.',
|
|
958
|
+
},
|
|
959
|
+
}
|
|
960
|
+
: {}),
|
|
961
|
+
});
|
|
962
|
+
reports.push(report);
|
|
963
|
+
statuses.set(action.action_id, status);
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (ledger.attempts.has(action.action_id)) {
|
|
967
|
+
const report = await finalizeAttemptedAction({
|
|
968
|
+
row,
|
|
969
|
+
action,
|
|
970
|
+
contract: options.contract,
|
|
971
|
+
contractSha256,
|
|
972
|
+
ledgerRoot,
|
|
973
|
+
ledger,
|
|
974
|
+
client: options.dataClient.client,
|
|
975
|
+
restBaseUrl: options.dataClient.restBaseUrl,
|
|
976
|
+
now: options.now,
|
|
977
|
+
recovered: true,
|
|
978
|
+
});
|
|
979
|
+
reports.push(report);
|
|
980
|
+
statuses.set(action.action_id, report.status);
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
const blockingDependencies = action.dependency_action_ids.filter((dependency) => statuses.get(dependency) !== 'executed');
|
|
984
|
+
if (blockingDependencies.length > 0) {
|
|
985
|
+
const report = contractRowReport({
|
|
986
|
+
row,
|
|
987
|
+
action,
|
|
988
|
+
status: 'blocked',
|
|
989
|
+
operation: 'blocked_dependency',
|
|
990
|
+
attemptConsumed: false,
|
|
991
|
+
readback: 'not_performed',
|
|
992
|
+
error: {
|
|
993
|
+
message: 'Action dependencies are not terminal successes; no request was emitted.',
|
|
994
|
+
details: { dependency_action_ids: blockingDependencies },
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
reports.push(report);
|
|
998
|
+
statuses.set(action.action_id, report.status);
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
let beforeRows;
|
|
1002
|
+
let transportFailed = false;
|
|
1003
|
+
try {
|
|
1004
|
+
beforeRows = await exactExecutionRows({
|
|
1005
|
+
client: options.dataClient.client,
|
|
1006
|
+
restBaseUrl: options.dataClient.restBaseUrl,
|
|
1007
|
+
table: action.table,
|
|
1008
|
+
id: action.id,
|
|
1009
|
+
version: action.version,
|
|
1010
|
+
});
|
|
1011
|
+
const before = beforeRows[0];
|
|
1012
|
+
const observedOperation = beforeRows.length === 0 ? 'insert' : 'save_draft';
|
|
1013
|
+
const beforeExact = Boolean(beforeRows.length === 1 &&
|
|
1014
|
+
before &&
|
|
1015
|
+
before.id === action.id &&
|
|
1016
|
+
before.version === action.version &&
|
|
1017
|
+
before.user_id === options.contract.owner.user_id &&
|
|
1018
|
+
before.state_code === 0 &&
|
|
1019
|
+
before.json_ordered &&
|
|
1020
|
+
sha256Json(before.json_ordered) === action.before_sha256);
|
|
1021
|
+
if (beforeRows.length > 1 ||
|
|
1022
|
+
observedOperation !== action.expected_operation ||
|
|
1023
|
+
(observedOperation === 'save_draft' && !beforeExact)) {
|
|
1024
|
+
throw new CliError('Execution action before-state or expected operation drifted.', {
|
|
1025
|
+
code: 'DATASET_SAVE_DRAFT_EXECUTION_BEFORE_DRIFT',
|
|
1026
|
+
exitCode: 1,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
if (row.type === 'flow') {
|
|
1030
|
+
const unresolvedReferences = await missingFlowRemoteReferences({
|
|
1031
|
+
runtime: options.runtime,
|
|
1032
|
+
fetchImpl: options.fetchImpl,
|
|
1033
|
+
timeoutMs: options.timeoutMs,
|
|
1034
|
+
cache: referenceOnlySupportCache,
|
|
1035
|
+
payload: row.payload,
|
|
1036
|
+
});
|
|
1037
|
+
if (unresolvedReferences.length > 0) {
|
|
1038
|
+
throw new CliError('Flow execution action has unresolved remote references.', {
|
|
1039
|
+
code: 'DATASET_SAVE_DRAFT_REMOTE_REFERENCE_UNRESOLVED',
|
|
1040
|
+
exitCode: 1,
|
|
1041
|
+
details: { references: unresolvedReferences },
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
const report = contractRowReport({
|
|
1048
|
+
row,
|
|
1049
|
+
action,
|
|
1050
|
+
status: 'failed',
|
|
1051
|
+
operation: action.expected_operation,
|
|
1052
|
+
attemptConsumed: false,
|
|
1053
|
+
readback: 'not_performed',
|
|
1054
|
+
error: serializeError(error),
|
|
1055
|
+
});
|
|
1056
|
+
reports.push(report);
|
|
1057
|
+
statuses.set(action.action_id, report.status);
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
const beforeDispatch = () => {
|
|
1062
|
+
appendExecutionEvent({
|
|
1063
|
+
ledgerRoot,
|
|
1064
|
+
ledger,
|
|
1065
|
+
contractSha256,
|
|
1066
|
+
action,
|
|
1067
|
+
eventType: 'attempt_emitted',
|
|
1068
|
+
outcome: null,
|
|
1069
|
+
recovered: false,
|
|
1070
|
+
recordedAtUtc: options.now(),
|
|
1071
|
+
});
|
|
1072
|
+
};
|
|
1073
|
+
if (action.expected_operation === 'insert') {
|
|
1074
|
+
await createDatasetRecord({
|
|
1075
|
+
transport: options.commandTransport,
|
|
1076
|
+
table: action.table,
|
|
1077
|
+
id: action.id,
|
|
1078
|
+
payload: row.payload,
|
|
1079
|
+
extraData: { ruleVerification: true },
|
|
1080
|
+
beforeDispatch,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
await saveDraftDatasetRecord({
|
|
1085
|
+
transport: options.commandTransport,
|
|
1086
|
+
table: action.table,
|
|
1087
|
+
id: action.id,
|
|
1088
|
+
version: action.version,
|
|
1089
|
+
payload: row.payload,
|
|
1090
|
+
extraData: { ruleVerification: true },
|
|
1091
|
+
beforeDispatch,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
if (error instanceof CliError && error.code === 'DATASET_COMMAND_BEFORE_DISPATCH_FAILED') {
|
|
1097
|
+
throw error;
|
|
1098
|
+
}
|
|
1099
|
+
// Once emission is durably recorded, transport outcomes are resolved by readback only.
|
|
1100
|
+
transportFailed = true;
|
|
1101
|
+
}
|
|
1102
|
+
const report = await finalizeAttemptedAction({
|
|
1103
|
+
row,
|
|
1104
|
+
action,
|
|
1105
|
+
contract: options.contract,
|
|
1106
|
+
contractSha256,
|
|
1107
|
+
ledgerRoot,
|
|
1108
|
+
ledger,
|
|
1109
|
+
client: options.dataClient.client,
|
|
1110
|
+
restBaseUrl: options.dataClient.restBaseUrl,
|
|
1111
|
+
now: options.now,
|
|
1112
|
+
recovered: transportFailed,
|
|
1113
|
+
});
|
|
1114
|
+
reports.push(report);
|
|
1115
|
+
statuses.set(action.action_id, report.status);
|
|
1116
|
+
}
|
|
1117
|
+
const failures = reports.filter((row) => ['failed', 'unknown', 'blocked'].includes(row.status));
|
|
1118
|
+
writeJsonLinesArtifact(options.files.progress_jsonl, reports);
|
|
1119
|
+
writeJsonLinesArtifact(options.files.failures_jsonl, failures);
|
|
1120
|
+
const unknown = reports.filter((row) => row.status === 'unknown').length;
|
|
1121
|
+
const failed = reports.filter((row) => row.status === 'failed').length;
|
|
1122
|
+
const blocked = reports.filter((row) => row.status === 'blocked').length;
|
|
1123
|
+
const report = {
|
|
1124
|
+
schema_version: 2,
|
|
1125
|
+
generated_at_utc: options.now(),
|
|
1126
|
+
input_path: options.inputPath,
|
|
1127
|
+
requested_type: options.requestedType,
|
|
1128
|
+
out_dir: options.outDir,
|
|
1129
|
+
commit: true,
|
|
1130
|
+
mode: 'commit',
|
|
1131
|
+
status: unknown > 0
|
|
1132
|
+
? 'completed_with_unknowns'
|
|
1133
|
+
: failed + blocked > 0
|
|
1134
|
+
? 'completed_with_failures'
|
|
1135
|
+
: 'completed',
|
|
1136
|
+
counts: {
|
|
1137
|
+
selected: options.preparedRows.length,
|
|
1138
|
+
prepared: 0,
|
|
1139
|
+
executed: reports.filter((row) => row.status === 'executed').length,
|
|
1140
|
+
failed,
|
|
1141
|
+
unknown,
|
|
1142
|
+
blocked,
|
|
1143
|
+
attempts_consumed: ledger.attempts.size,
|
|
1144
|
+
by_table: byTable(options.preparedRows),
|
|
1145
|
+
operations: operationCount(reports),
|
|
1146
|
+
},
|
|
1147
|
+
files: options.files,
|
|
1148
|
+
rows: reports,
|
|
1149
|
+
execution_contract: {
|
|
1150
|
+
path: path.resolve(options.contractPath),
|
|
1151
|
+
sha256: contractSha256,
|
|
1152
|
+
execution_id: options.contract.execution_id,
|
|
1153
|
+
target_mode: 'owner_draft',
|
|
1154
|
+
},
|
|
1155
|
+
};
|
|
1156
|
+
writeJsonArtifact(options.files.summary_json, report);
|
|
1157
|
+
return report;
|
|
1158
|
+
}
|
|
534
1159
|
export async function runDatasetSaveDraft(options) {
|
|
535
1160
|
const now = options.now ?? new Date();
|
|
536
1161
|
const inputPath = path.resolve(options.inputPath);
|
|
@@ -542,6 +1167,18 @@ export async function runDatasetSaveDraft(options) {
|
|
|
542
1167
|
const files = buildFiles(outDir);
|
|
543
1168
|
const preparedRows = prepareRows(inputPath, options.rawInput, requestedType);
|
|
544
1169
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1170
|
+
const executionContractPath = options.executionContractPath
|
|
1171
|
+
? path.resolve(options.executionContractPath)
|
|
1172
|
+
: null;
|
|
1173
|
+
const executionContract = executionContractPath
|
|
1174
|
+
? parseExecutionContract(readJsonFile(executionContractPath, 'Dataset save-draft execution contract'))
|
|
1175
|
+
: null;
|
|
1176
|
+
if (executionContract && !commit) {
|
|
1177
|
+
executionContractError('Execution contract mode requires --commit.');
|
|
1178
|
+
}
|
|
1179
|
+
if (executionContract) {
|
|
1180
|
+
bindExecutionContractRows(executionContract, preparedRows);
|
|
1181
|
+
}
|
|
545
1182
|
if (commit && (!options.env || !options.fetchImpl)) {
|
|
546
1183
|
throw new CliError('Dataset save-draft commit requires env and fetch runtime bindings.', {
|
|
547
1184
|
code: 'DATASET_SAVE_DRAFT_RUNTIME_REQUIRED',
|
|
@@ -567,6 +1204,25 @@ export async function runDatasetSaveDraft(options) {
|
|
|
567
1204
|
? createSupabaseDataClient(runtime, options.fetchImpl, timeoutMs)
|
|
568
1205
|
: null;
|
|
569
1206
|
const referenceOnlySupportCache = new Map();
|
|
1207
|
+
if (executionContract && executionContractPath) {
|
|
1208
|
+
return runExecutionContractBatch({
|
|
1209
|
+
contractPath: executionContractPath,
|
|
1210
|
+
contract: executionContract,
|
|
1211
|
+
preparedRows,
|
|
1212
|
+
allowReferenceOnlySupport,
|
|
1213
|
+
files,
|
|
1214
|
+
inputPath,
|
|
1215
|
+
requestedType,
|
|
1216
|
+
outDir,
|
|
1217
|
+
env: options.env,
|
|
1218
|
+
runtime: runtime,
|
|
1219
|
+
commandTransport: commandTransport,
|
|
1220
|
+
dataClient: dataClient,
|
|
1221
|
+
fetchImpl: options.fetchImpl,
|
|
1222
|
+
timeoutMs,
|
|
1223
|
+
now: () => now.toISOString(),
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
570
1226
|
const reports = [];
|
|
571
1227
|
for (const row of preparedRows) {
|
|
572
1228
|
const preparedFailure = buildPreparedFailure(row, allowReferenceOnlySupport);
|
|
@@ -712,6 +1368,13 @@ export const __testInternals = {
|
|
|
712
1368
|
compareVersions,
|
|
713
1369
|
defaultOutDir,
|
|
714
1370
|
detectType,
|
|
1371
|
+
decodeExecutionActor,
|
|
1372
|
+
bindExecutionContractRows,
|
|
1373
|
+
executionLedgerRoot,
|
|
1374
|
+
executionLedgerPath,
|
|
1375
|
+
executionActionBindingSha256,
|
|
1376
|
+
loadExecutionLedger,
|
|
1377
|
+
exactDesiredReadback,
|
|
715
1378
|
extractIdentity,
|
|
716
1379
|
flowType,
|
|
717
1380
|
isElementaryFlowPayload,
|
|
@@ -721,6 +1384,10 @@ export const __testInternals = {
|
|
|
721
1384
|
normalizeType,
|
|
722
1385
|
operationCount,
|
|
723
1386
|
parseVisibleRows,
|
|
1387
|
+
parseExecutionContract,
|
|
1388
|
+
parseExecutionRows,
|
|
1389
|
+
parseLedgerEvent,
|
|
1390
|
+
projectRefFromApiBaseUrl,
|
|
724
1391
|
prepareRows,
|
|
725
1392
|
remoteReferenceFallbackKey,
|
|
726
1393
|
selectedRow,
|