owlrunkit 0.14.0 → 0.15.1

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.
@@ -1,8 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import {
3
3
  existsSync,
4
+ lstatSync,
4
5
  mkdirSync,
5
6
  readFileSync,
7
+ readdirSync,
6
8
  realpathSync,
7
9
  renameSync,
8
10
  rmSync,
@@ -12,7 +14,7 @@ import { isAbsolute, relative, resolve, win32 } from "node:path";
12
14
  import { fileURLToPath } from "node:url";
13
15
 
14
16
  export const CONTRACT_VERSION = "0.2";
15
- export const CORE_VERSION = "0.14.0";
17
+ export const CORE_VERSION = "0.15.1";
16
18
  export const RUNTIME_ROOT = ".owlcoda/runkit";
17
19
 
18
20
  const CORE_FILES = [
@@ -72,6 +74,8 @@ const CORE_FILES = [
72
74
  "schemas/visual-smoke-request-v1.schema.json",
73
75
  "schemas/visual-smoke-result-v1.schema.json",
74
76
  "schemas/verification-context-v1.schema.json",
77
+ "schemas/verification-finalize-attempt-v1.schema.json",
78
+ "schemas/verification-preflight-v1.schema.json",
75
79
  "schemas/verification-plan-v1.schema.json",
76
80
  "schemas/verification-receipt-v2.schema.json",
77
81
  "schemas/verify-plan-request-v1.schema.json",
@@ -136,6 +140,22 @@ function migrationReceiptName(migration, fromCore, toCore) {
136
140
  return `config-v02-core-refresh-${from}-to-${to}.json`;
137
141
  }
138
142
 
143
+ function writeMigrationReceiptAppendOnly(receiptsRoot, baseName, receipt) {
144
+ const bytes = `${JSON.stringify(receipt, null, 2)}\n`;
145
+ const stem = baseName.slice(0, -".json".length);
146
+ for (let attempt = 1; ; attempt += 1) {
147
+ const receiptName = attempt === 1
148
+ ? baseName
149
+ : `${stem}-attempt-${String(attempt).padStart(3, "0")}.json`;
150
+ try {
151
+ writeFileSync(resolve(receiptsRoot, receiptName), bytes, { flag: "wx" });
152
+ return receiptName;
153
+ } catch (error) {
154
+ if (error?.code !== "EEXIST") throw error;
155
+ }
156
+ }
157
+ }
158
+
139
159
  function migrateProjectConfig({ runtimeRoot, configPath, config, migration }) {
140
160
  validateCoreIdentity(config.core);
141
161
  const next = projectConfig();
@@ -150,8 +170,11 @@ function migrateProjectConfig({ runtimeRoot, configPath, config, migration }) {
150
170
  };
151
171
  const receiptsRoot = resolve(runtimeRoot, "config-migration-receipts");
152
172
  mkdirSync(receiptsRoot, { recursive: true });
153
- const receiptName = migrationReceiptName(migration, config.core, next.core);
154
- atomicWriteJson(resolve(receiptsRoot, receiptName), receipt);
173
+ const receiptName = writeMigrationReceiptAppendOnly(
174
+ receiptsRoot,
175
+ migrationReceiptName(migration, config.core, next.core),
176
+ receipt,
177
+ );
155
178
  atomicWriteJson(configPath, next);
156
179
  return `${RUNTIME_ROOT}/config-migration-receipts/${receiptName}`;
157
180
  }
@@ -211,9 +234,15 @@ export function validateLeaseOwnedPaths(paths) {
211
234
 
212
235
  export function validateExecutionPin({ expected, actual } = {}) {
213
236
  const fields = ["contractVersion", "coreVersion", "coreManifestSha256", "coreSourceRef"];
214
- const malformed = fields.filter((field) => typeof expected?.[field] !== "string" || typeof actual?.[field] !== "string");
215
- if (malformed.length > 0) {
216
- return { status: "invalid_input", exitCode: 3, issues: malformed.map((field) => `missing engine pin field: ${field}`) };
237
+ try {
238
+ validateCoreIdentity(expected, { requireSourceBinding: false });
239
+ validateCoreIdentity(actual, { requireSourceBinding: false });
240
+ } catch (error) {
241
+ return {
242
+ status: "invalid_input",
243
+ exitCode: 3,
244
+ issues: [error instanceof Error ? `invalid engine pin: ${error.message}` : String(error)],
245
+ };
217
246
  }
218
247
  const issues = fields.filter((field) => expected[field] !== actual[field]).map((field) => `${field} changed during execution`);
219
248
  return issues.length === 0
@@ -221,11 +250,27 @@ export function validateExecutionPin({ expected, actual } = {}) {
221
250
  : { status: "engine_changed_during_execution", exitCode: 2, issues };
222
251
  }
223
252
 
224
- function validateCoreIdentity(core) {
253
+ function validateCoreIdentity(core, { requireSourceBinding = true } = {}) {
225
254
  if (!isRecord(core)) throw new Error("Core identity must be an object.");
255
+ const coreKeys = ["contractVersion", "coreManifestSha256", "coreSourceRef", "coreVersion"];
256
+ const unsupportedCoreKeys = Object.keys(core).filter(key => !coreKeys.includes(key));
257
+ if (unsupportedCoreKeys.length > 0) {
258
+ throw new Error(`Core identity contains unsupported field: ${unsupportedCoreKeys[0]}`);
259
+ }
226
260
  if (typeof core.contractVersion !== "string" || typeof core.coreVersion !== "string") throw new Error("Core identity requires contractVersion and coreVersion.");
261
+ if (!new Set(["0.1", CONTRACT_VERSION]).has(core.contractVersion)) {
262
+ throw new Error("Core identity contractVersion must be a supported RunKit contract version.");
263
+ }
264
+ if (!/^\d+\.\d+\.\d+$/u.test(core.coreVersion)) {
265
+ throw new Error("Core identity coreVersion must be a three-part semantic version.");
266
+ }
227
267
  if (!isSha256Ref(core.coreManifestSha256)) throw new Error("Core identity requires coreManifestSha256.");
228
- if (typeof core.coreSourceRef !== "string" || core.coreSourceRef.length === 0) throw new Error("Core identity requires coreSourceRef.");
268
+ if (typeof core.coreSourceRef !== "string" || core.coreSourceRef.length === 0) {
269
+ throw new Error("Core identity coreSourceRef must be a non-empty string.");
270
+ }
271
+ if (requireSourceBinding && core.coreSourceRef !== `artifact:${core.coreManifestSha256}`) {
272
+ throw new Error("Core identity coreSourceRef must bind coreManifestSha256.");
273
+ }
229
274
  }
230
275
 
231
276
  export function validateProjectConfigV2(config) {
@@ -258,10 +303,207 @@ export function validateProjectConfigV2(config) {
258
303
  }
259
304
  }
260
305
 
306
+ function inspectTrustedCloseout(executionRoot, runId) {
307
+ const closeoutPath = resolve(executionRoot, "closeout-receipt.json");
308
+ try {
309
+ const closeoutStat = lstatSync(closeoutPath);
310
+ if (closeoutStat.isSymbolicLink() || !closeoutStat.isFile()) {
311
+ return {
312
+ trusted: false,
313
+ decision: null,
314
+ issues: [`Closeout receipt must be a regular file: ${runId}`],
315
+ };
316
+ }
317
+ const closeout = JSON.parse(readFileSync(closeoutPath, "utf8"));
318
+ if (closeout?.artifact?.payload?.runId !== runId) {
319
+ return {
320
+ trusted: false,
321
+ decision: null,
322
+ issues: [`Closeout receipt runId mismatch: ${runId}`],
323
+ };
324
+ }
325
+ const artifactGate = validateCoreArtifact(closeout.artifact);
326
+ if (!artifactGate.valid) {
327
+ return {
328
+ trusted: false,
329
+ decision: null,
330
+ issues: artifactGate.issues.map(issue => `Closeout receipt is invalid: ${runId}: ${issue}`),
331
+ };
332
+ }
333
+ if (closeout.acceptanceSha256 !== artifactGate.acceptanceSha256
334
+ || closeout.artifactSha256 !== artifactGate.artifactSha256) {
335
+ return {
336
+ trusted: false,
337
+ decision: null,
338
+ issues: [`Closeout receipt hash mismatch: ${runId}`],
339
+ };
340
+ }
341
+ const payload = closeout.artifact.payload;
342
+ if (!new Set(["accepted", "rejected", "blocked"]).has(payload.decision)
343
+ || payload.authorizationGranted !== false) {
344
+ return {
345
+ trusted: false,
346
+ decision: null,
347
+ issues: [`Closeout receipt decision or authorization is invalid: ${runId}`],
348
+ };
349
+ }
350
+ const expectedPayloadKeys = payload.decision === "accepted"
351
+ ? ["authorizationGranted", "decision", "runId", "verification"]
352
+ : ["authorizationGranted", "decision", "runId"];
353
+ const unsupportedPayloadKeys = Object.keys(payload)
354
+ .filter(key => !expectedPayloadKeys.includes(key));
355
+ if (unsupportedPayloadKeys.length > 0) {
356
+ return {
357
+ trusted: false,
358
+ decision: null,
359
+ issues: [
360
+ `Closeout receipt payload contains unsupported field: ${runId}: ${unsupportedPayloadKeys[0]}`,
361
+ ],
362
+ };
363
+ }
364
+ const verificationIssues = acceptedCloseoutVerificationIssues(closeout.artifact);
365
+ if (verificationIssues.length > 0) {
366
+ return {
367
+ trusted: false,
368
+ decision: null,
369
+ issues: verificationIssues.map(issue => `Closeout receipt is invalid: ${runId}: ${issue}`),
370
+ };
371
+ }
372
+ const pinPath = resolve(executionRoot, "engine-pin.json");
373
+ const pinStat = lstatSync(pinPath);
374
+ if (pinStat.isSymbolicLink() || !pinStat.isFile()) {
375
+ return {
376
+ trusted: false,
377
+ decision: null,
378
+ issues: [`Execution engine pin must be a regular file: ${runId}`],
379
+ };
380
+ }
381
+ const pinGate = validateExecutionPin({
382
+ expected: JSON.parse(readFileSync(pinPath, "utf8")),
383
+ actual: closeout.artifact.core,
384
+ });
385
+ if (pinGate.status !== "valid") {
386
+ return {
387
+ trusted: false,
388
+ decision: null,
389
+ issues: pinGate.issues.map(issue => `Closeout engine pin mismatch: ${runId}: ${issue}`),
390
+ };
391
+ }
392
+ return {
393
+ trusted: true,
394
+ decision: payload.decision,
395
+ issues: [],
396
+ };
397
+ } catch (error) {
398
+ return {
399
+ trusted: false,
400
+ decision: null,
401
+ issues: [
402
+ `Closeout receipt or engine pin is invalid: ${runId}: ${
403
+ error instanceof Error ? error.message : String(error)
404
+ }`,
405
+ ],
406
+ };
407
+ }
408
+ }
409
+
410
+ export function inspectProjectUpgradeSafety({ workspaceRoot } = {}) {
411
+ if (typeof workspaceRoot !== "string" || workspaceRoot.length === 0) {
412
+ throw new Error("workspaceRoot is required.");
413
+ }
414
+ const root = realpathSync(workspaceRoot);
415
+ const executionsRoot = resolve(root, RUNTIME_ROOT, "executions");
416
+ if (!existsSync(executionsRoot)) {
417
+ return {
418
+ status: "safe",
419
+ activeRunIds: [],
420
+ activeLeaseIds: [],
421
+ issues: [],
422
+ };
423
+ }
424
+ const executionsStat = lstatSync(executionsRoot);
425
+ if (executionsStat.isSymbolicLink() || !executionsStat.isDirectory()) {
426
+ return {
427
+ status: "unsafe",
428
+ activeRunIds: [],
429
+ activeLeaseIds: [],
430
+ issues: ["RunKit executions root must be a regular directory."],
431
+ };
432
+ }
433
+ const activeRunIds = [];
434
+ const activeLeaseIds = [];
435
+ const issues = [];
436
+ const entries = readdirSync(executionsRoot, { withFileTypes: true })
437
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
438
+ for (const entry of entries) {
439
+ if (entry.isSymbolicLink()) {
440
+ issues.push(`Execution directory must not be a symlink: ${entry.name}`);
441
+ continue;
442
+ }
443
+ if (!entry.isDirectory()) continue;
444
+ const executionRoot = resolve(executionsRoot, entry.name);
445
+ const closeoutPath = resolve(executionRoot, "closeout-receipt.json");
446
+ let skipHistoricalLeases = false;
447
+ if (!existsSync(closeoutPath)) {
448
+ activeRunIds.push(entry.name);
449
+ } else {
450
+ const closeout = inspectTrustedCloseout(executionRoot, entry.name);
451
+ issues.push(...closeout.issues);
452
+ skipHistoricalLeases = closeout.trusted
453
+ && new Set(["rejected", "blocked"]).has(closeout.decision);
454
+ }
455
+ if (skipHistoricalLeases) continue;
456
+ const leasesRoot = resolve(executionRoot, "leases");
457
+ if (!existsSync(leasesRoot)) continue;
458
+ const leasesStat = lstatSync(leasesRoot);
459
+ if (leasesStat.isSymbolicLink() || !leasesStat.isDirectory()) {
460
+ issues.push(`Lease directory must be a regular directory: ${entry.name}`);
461
+ continue;
462
+ }
463
+ const leaseEntries = readdirSync(leasesRoot, { withFileTypes: true })
464
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
465
+ for (const leaseEntry of leaseEntries) {
466
+ if (!leaseEntry.name.endsWith(".json")) continue;
467
+ if (leaseEntry.isSymbolicLink() || !leaseEntry.isFile()) {
468
+ issues.push(`Lease artifact must be a regular file: ${entry.name}/${leaseEntry.name}`);
469
+ continue;
470
+ }
471
+ try {
472
+ const lease = JSON.parse(readFileSync(resolve(leasesRoot, leaseEntry.name), "utf8"));
473
+ if (lease.state === "active") {
474
+ activeLeaseIds.push(`${entry.name}:${lease.workItemId ?? leaseEntry.name}`);
475
+ } else if (lease.state !== "released") {
476
+ issues.push(`Lease state is invalid: ${entry.name}/${leaseEntry.name}`);
477
+ }
478
+ } catch {
479
+ issues.push(`Lease artifact is invalid JSON: ${entry.name}/${leaseEntry.name}`);
480
+ }
481
+ }
482
+ }
483
+ return {
484
+ status: activeRunIds.length === 0
485
+ && activeLeaseIds.length === 0
486
+ && issues.length === 0
487
+ ? "safe"
488
+ : "unsafe",
489
+ activeRunIds,
490
+ activeLeaseIds,
491
+ issues,
492
+ };
493
+ }
494
+
261
495
  function validateProducer(producer) {
262
496
  if (!isRecord(producer) || typeof producer.adapterKind !== "string" || typeof producer.adapterVersion !== "string") {
263
497
  throw new Error("Producer requires adapterKind and adapterVersion.");
264
498
  }
499
+ if (producer.adapterKind.length === 0 || producer.adapterVersion.length === 0) {
500
+ throw new Error("Producer adapterKind and adapterVersion must be non-empty strings.");
501
+ }
502
+ const producerKeys = ["adapterKind", "adapterVersion"];
503
+ const unsupportedProducerKeys = Object.keys(producer).filter(key => !producerKeys.includes(key));
504
+ if (unsupportedProducerKeys.length > 0) {
505
+ throw new Error(`Producer contains unsupported field: ${unsupportedProducerKeys[0]}`);
506
+ }
265
507
  }
266
508
 
267
509
  function validateExtensions(extensions) {
@@ -301,7 +543,18 @@ export function createCoreArtifact({ core = currentCoreIdentity(), producer, pay
301
543
  export function validateCoreArtifact(artifact) {
302
544
  try {
303
545
  if (!isRecord(artifact) || !new Set(["RunKitCoreArtifactV1", "RunKitCoreArtifactV2"]).has(artifact.schemaVersion)) throw new Error("Unsupported core artifact schemaVersion.");
546
+ const artifactKeys = ["core", "extensions", "payload", "producer", "schemaVersion"];
547
+ const unsupportedArtifactKeys = Object.keys(artifact).filter(key => !artifactKeys.includes(key));
548
+ if (unsupportedArtifactKeys.length > 0) {
549
+ throw new Error(`Core artifact contains unsupported field: ${unsupportedArtifactKeys[0]}`);
550
+ }
304
551
  validateCoreIdentity(artifact.core);
552
+ const expectedContractVersion = artifact.schemaVersion === "RunKitCoreArtifactV1"
553
+ ? "0.1"
554
+ : CONTRACT_VERSION;
555
+ if (artifact.core.contractVersion !== expectedContractVersion) {
556
+ throw new Error(`Core artifact ${artifact.schemaVersion} requires contractVersion ${expectedContractVersion}.`);
557
+ }
305
558
  validateProducer(artifact.producer);
306
559
  if (!isRecord(artifact.payload)) throw new Error("Core artifact payload must be an object.");
307
560
  validateExtensions(artifact.extensions);
@@ -340,10 +593,10 @@ export async function initializeProjectRunKit({ workspaceRoot } = {}) {
340
593
  const runtimeRoot = resolve(root, RUNTIME_ROOT);
341
594
  const remainder = relative(root, runtimeRoot);
342
595
  if (remainder.startsWith("..") || isAbsolute(remainder)) throw new Error("Runtime root escapes the workspace.");
343
- mkdirSync(resolve(runtimeRoot, "executions"), { recursive: true });
344
596
  const configPath = resolve(runtimeRoot, "config.json");
345
597
  const profilesPath = resolve(runtimeRoot, "profiles.json");
346
598
  if (!existsSync(configPath)) {
599
+ mkdirSync(resolve(runtimeRoot, "executions"), { recursive: true });
347
600
  atomicWriteJson(configPath, projectConfig());
348
601
  } else {
349
602
  const config = JSON.parse(readFileSync(configPath, "utf8"));
@@ -355,6 +608,16 @@ export async function initializeProjectRunKit({ workspaceRoot } = {}) {
355
608
  if (config.schemaVersion === "OwlCodaRunKitConfigV2" && currentGate.status === "valid") {
356
609
  // The project already points at this exact Core identity.
357
610
  } else if (config.schemaVersion === "OwlCodaRunKitConfigV1" && config.core?.contractVersion === "0.1") {
611
+ const safety = inspectProjectUpgradeSafety({ workspaceRoot: root });
612
+ if (safety.status !== "safe") {
613
+ return {
614
+ ...safety,
615
+ status: "upgrade_blocked_active_execution",
616
+ exitCode: 2,
617
+ runtimeRoot: RUNTIME_ROOT,
618
+ core: current,
619
+ };
620
+ }
358
621
  const migrationReceipt = migrateProjectConfig({
359
622
  runtimeRoot,
360
623
  configPath,
@@ -366,6 +629,16 @@ export async function initializeProjectRunKit({ workspaceRoot } = {}) {
366
629
  }
367
630
  return { status: "upgraded", exitCode: 0, runtimeRoot: RUNTIME_ROOT, core: current, migrationReceipt };
368
631
  } else if (config.schemaVersion === "OwlCodaRunKitConfigV2" && config.core?.contractVersion === CONTRACT_VERSION) {
632
+ const safety = inspectProjectUpgradeSafety({ workspaceRoot: root });
633
+ if (safety.status !== "safe") {
634
+ return {
635
+ ...safety,
636
+ status: "upgrade_blocked_active_execution",
637
+ exitCode: 2,
638
+ runtimeRoot: RUNTIME_ROOT,
639
+ core: current,
640
+ };
641
+ }
369
642
  const migrationReceipt = migrateProjectConfig({
370
643
  runtimeRoot,
371
644
  configPath,
@@ -380,6 +653,7 @@ export async function initializeProjectRunKit({ workspaceRoot } = {}) {
380
653
  throw new Error(`Unsupported project config migration: ${config.schemaVersion ?? "missing schemaVersion"}.`);
381
654
  }
382
655
  }
656
+ mkdirSync(resolve(runtimeRoot, "executions"), { recursive: true });
383
657
  if (!existsSync(profilesPath)) {
384
658
  writeFileSync(profilesPath, `${JSON.stringify({ schemaVersion: "OwlCodaRunKitProfilesV1", profiles: [] }, null, 2)}\n`);
385
659
  }