@the-open-engine/zeroshot 6.23.0 → 6.25.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.
Files changed (51) hide show
  1. package/cli/index.js +37 -2
  2. package/docker/zeroshot-cluster/Dockerfile +7 -0
  3. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  4. package/lib/agent-cli-provider/adapters/omp.js +37 -11
  5. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  6. package/lib/agent-cli-provider/omp-release.d.ts +3 -0
  7. package/lib/agent-cli-provider/omp-release.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/omp-release.js +20 -1
  9. package/lib/agent-cli-provider/omp-release.js.map +1 -1
  10. package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
  11. package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
  12. package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
  13. package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
  14. package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
  15. package/lib/agent-cli-provider/provider-registry.d.ts +20 -8
  16. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/provider-registry.js +56 -9
  18. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  19. package/lib/agent-cli-provider/types.d.ts +2 -0
  20. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  21. package/lib/agent-cli-provider/types.js.map +1 -1
  22. package/lib/docker-config.js +122 -5
  23. package/package.json +2 -2
  24. package/src/agent/agent-lifecycle.js +78 -3
  25. package/src/agent/agent-task-executor.js +72 -3
  26. package/src/agent/provider-session.js +112 -2
  27. package/src/agent-cli-provider/adapters/omp.ts +41 -11
  28. package/src/agent-cli-provider/omp-release.ts +26 -0
  29. package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
  30. package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
  31. package/src/agent-cli-provider/provider-registry.ts +102 -20
  32. package/src/agent-cli-provider/types.ts +4 -0
  33. package/src/isolation-manager.js +535 -89
  34. package/src/omp-blob-root.js +110 -0
  35. package/src/omp-config-overlay.js +9 -1
  36. package/src/omp-execution-fingerprint.js +62 -0
  37. package/src/omp-session-limits.js +17 -0
  38. package/src/omp-session-partition.js +297 -0
  39. package/src/omp-session-verifier.js +576 -0
  40. package/src/orchestrator.js +11 -1
  41. package/src/preflight.js +15 -2
  42. package/task-lib/commands/clean.js +23 -0
  43. package/task-lib/commands/resume.js +42 -0
  44. package/task-lib/commands/run.js +65 -0
  45. package/task-lib/omp-session-cleanup.js +160 -0
  46. package/task-lib/omp-session-ownership-schema.js +262 -0
  47. package/task-lib/omp-session-ownership.js +332 -0
  48. package/task-lib/omp-storage-root.js +35 -0
  49. package/task-lib/rpc-watcher.js +332 -2
  50. package/task-lib/runner.js +195 -4
  51. package/task-lib/store.js +42 -7
@@ -2,14 +2,56 @@ import chalk from 'chalk';
2
2
  import { createRequire } from 'module';
3
3
  import { getTask } from '../store.js';
4
4
  import { spawnTask } from '../runner.js';
5
+ import { validateOwnedByTask } from '../omp-session-ownership-schema.js';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
  const { providerSupportsCapability } = require('../../lib/provider-names.js');
8
9
 
10
+ /**
11
+ * Manual standalone resume (`zeroshot task resume <id>`) reuses the *exact* persisted partition
12
+ * under the storage root recorded on the owner row, and asserts the complete committed tuple —
13
+ * including the partition identity, which the cluster path can only learn from the row itself.
14
+ * `state === 'committed'` is required: a provisional or cleanup-required record never durably
15
+ * proved a resumable session, so anything less fails closed to a fresh context.
16
+ */
17
+ function buildOmpResumeTaskOptions(task) {
18
+ const ownership = validateOwnedByTask(task.ompSessionOwnership, task.id);
19
+ if (!ownership) {
20
+ throw new Error(`Task ${task.id} has no valid OMP session ownership record; refusing resume.`);
21
+ }
22
+ if (ownership.state !== 'committed' || !ownership.session || !ownership.partitionIdentity) {
23
+ throw new Error(
24
+ `Task ${task.id} OMP session ownership is '${ownership.state}', not a committed resumable session; refusing resume.`
25
+ );
26
+ }
27
+ return {
28
+ cwd: ownership.canonicalWorkspace,
29
+ provider: task.provider,
30
+ storageRoot: ownership.storageRoot,
31
+ clusterId: ownership.owner.clusterId,
32
+ agentId: ownership.owner.agentId,
33
+ ompResume: {
34
+ priorOwnerTaskId: task.id,
35
+ partitionId: ownership.partitionId,
36
+ sessionFileName: ownership.session.fileName,
37
+ expectedSessionId: ownership.session.sessionId,
38
+ expectedPartitionIdentity: ownership.partitionIdentity,
39
+ expectedSessionFileIdentity: ownership.session.fileIdentity,
40
+ expectedArtifactManifestDigest: ownership.session.artifactManifestDigest,
41
+ expectedExecutionFingerprint: ownership.session.executionFingerprint,
42
+ expectedSelectedProvider: ownership.session.selectedProvider,
43
+ expectedSelectedModel: ownership.session.selectedModel,
44
+ },
45
+ };
46
+ }
47
+
9
48
  export function buildResumeTaskOptions(task) {
10
49
  if (!providerSupportsCapability(task.provider, 'sessionResume')) {
11
50
  throw new Error(`Provider ${task.provider} does not support safe session resume.`);
12
51
  }
52
+ if (task.provider === 'omp') {
53
+ return buildOmpResumeTaskOptions(task);
54
+ }
13
55
  if (
14
56
  task.requestedResumeSessionId &&
15
57
  (task.status !== 'completed' || task.resumeIdentityVerified !== true)
@@ -1,6 +1,70 @@
1
1
  import chalk from 'chalk';
2
2
  import { shouldUseAttachableWatcher, spawnTask } from '../runner.js';
3
3
 
4
+ // Every field the agent's `providerSession.ompSession` snapshot carries, plus the outer tuple's
5
+ // session ID and the prior owner's task id. All are required: the descriptor is only ever built
6
+ // from a complete committed record, and task-lib/runner.js re-checks every one of them against
7
+ // that record before a task row exists. Missing/extra/mistyped fields fail closed here.
8
+ const OMP_RESUME_STRING_FIELDS = [
9
+ 'priorOwnerTaskId',
10
+ 'partitionId',
11
+ 'sessionFileName',
12
+ 'expectedSessionId',
13
+ 'expectedArtifactManifestDigest',
14
+ 'expectedExecutionFingerprint',
15
+ 'expectedSelectedProvider',
16
+ 'expectedSelectedModel',
17
+ ];
18
+ const OMP_RESUME_IDENTITY_FIELDS = ['expectedSessionFileIdentity'];
19
+ const OMP_RESUME_OPTIONAL_IDENTITY_FIELDS = ['expectedPartitionIdentity'];
20
+ const OMP_RESUME_ALLOWED_FIELDS = new Set([
21
+ ...OMP_RESUME_STRING_FIELDS,
22
+ ...OMP_RESUME_IDENTITY_FIELDS,
23
+ ...OMP_RESUME_OPTIONAL_IDENTITY_FIELDS,
24
+ ]);
25
+
26
+ function isIdentityShape(value) {
27
+ return (
28
+ value !== null &&
29
+ typeof value === 'object' &&
30
+ !Array.isArray(value) &&
31
+ /^(0|[1-9][0-9]*)$/.test(String(value.device)) &&
32
+ /^(0|[1-9][0-9]*)$/.test(String(value.inode))
33
+ );
34
+ }
35
+
36
+ export function parseOmpResumeDescriptor(raw) {
37
+ if (!raw) return undefined;
38
+ let descriptor;
39
+ try {
40
+ descriptor = JSON.parse(raw);
41
+ } catch (error) {
42
+ throw new Error(`--omp-resume must be a JSON descriptor: ${error.message}`);
43
+ }
44
+ if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) {
45
+ throw new Error('--omp-resume descriptor must be a JSON object.');
46
+ }
47
+ const unknown = Object.keys(descriptor).filter((key) => !OMP_RESUME_ALLOWED_FIELDS.has(key));
48
+ if (unknown.length > 0) {
49
+ throw new Error(`--omp-resume descriptor has unknown field(s): ${unknown.join(', ')}.`);
50
+ }
51
+ const missing = [
52
+ ...OMP_RESUME_STRING_FIELDS.filter(
53
+ (field) => typeof descriptor[field] !== 'string' || descriptor[field].length === 0
54
+ ),
55
+ ...OMP_RESUME_IDENTITY_FIELDS.filter((field) => !isIdentityShape(descriptor[field])),
56
+ ];
57
+ if (missing.length > 0) {
58
+ throw new Error(`--omp-resume descriptor is missing/invalid field(s): ${missing.join(', ')}.`);
59
+ }
60
+ for (const field of OMP_RESUME_OPTIONAL_IDENTITY_FIELDS) {
61
+ if (descriptor[field] !== undefined && !isIdentityShape(descriptor[field])) {
62
+ throw new Error(`--omp-resume descriptor field ${field} is not a device/inode identity.`);
63
+ }
64
+ }
65
+ return descriptor;
66
+ }
67
+
4
68
  export async function runTask(prompt, options = {}) {
5
69
  if (!prompt || prompt.trim().length === 0) {
6
70
  console.log(chalk.red('Error: Prompt is required'));
@@ -36,6 +100,7 @@ export async function runTask(prompt, options = {}) {
36
100
  provider: options.provider,
37
101
  resume: options.resume,
38
102
  continue: options.continue,
103
+ ompResume: parseOmpResumeDescriptor(options.ompResume),
39
104
  outputFormat,
40
105
  jsonSchema,
41
106
  mcpConfig: options.mcpConfig,
@@ -0,0 +1,160 @@
1
+ // The single implementation behind all three OMP session-partition cleanup surfaces required by
2
+ // issue #866: standalone task `clean` (task-lib/commands/clean.js), cluster clear
3
+ // (cli/index.js deleteClusterData, also reached by `zeroshot purge`), and global `purge`
4
+ // (cli/index.js, which runs cluster clear then `clean --all`).
5
+ //
6
+ // Three invariants hold on every surface:
7
+ // * A committed session stays available for resume until its own task record is being removed —
8
+ // cleanup is driven by the task row, never by scanning the partition tree for orphans.
9
+ // * A partition is never deleted while any *other* row holds an authoritative claim on it.
10
+ // Several rows can name one partition at once: a resumed row is inserted before its owner
11
+ // transfer runs, so two competing resumes of one committed session put three rows on it. The
12
+ // fence is therefore over every authoritative row (provisional or committed), never over the
13
+ // committed rows alone — after a transfer the live owner is `provisional` and no row is
14
+ // committed at all.
15
+ // * The shared, machine-wide OMP CAS blob root (src/omp-blob-root.js) is never touched. Blobs
16
+ // are addressed from *other* sessions' JSONL too, so deleting one is data loss for unrelated
17
+ // work; stageOmpSessionPartitionForDeletion refuses any path that resolves inside it.
18
+ //
19
+ // An unsafe or unresolvable path preserves the owner record with an actionable warning instead of
20
+ // deleting, so the operator can inspect it and the cleanup stays durably retryable.
21
+ import { loadTasks, updateTask, getTaskStoreDatabase } from './store.js';
22
+ import { findAuthoritativeOwnersForPartition } from './omp-session-ownership.js';
23
+ import {
24
+ serializeOmpSessionOwnership,
25
+ validateOwnedByTask,
26
+ } from './omp-session-ownership-schema.js';
27
+ import { createRequire } from 'module';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const {
31
+ removeStagedOmpSessionPartition,
32
+ stageOmpSessionPartitionForDeletion,
33
+ } = require('../src/omp-session-partition.js');
34
+
35
+ /**
36
+ * Take the partition away from its canonical name under a task-store write fence, then remove the
37
+ * staged tree outside it.
38
+ *
39
+ * The fence spans exactly three steps: "this row still holds the record cleanup validated", "no
40
+ * other row holds an authoritative claim on this partition", and "the partition no longer answers
41
+ * to its canonical name". Without it, a resume could insert its provisional row — or win its
42
+ * ownership transfer — in the gap between the checks and the rename, and cleanup would move a live
43
+ * session out from under it. `BEGIN IMMEDIATE` takes the write lock up front, so every competing
44
+ * ownership write is serialized either wholly before the checks (and is therefore seen by them) or
45
+ * wholly after the rename, where it lands on a partition that is already gone and fails that turn
46
+ * closed rather than costing a live one its data.
47
+ *
48
+ * The row re-read closes the same race from the caller's side: every cleanup surface iterates a
49
+ * task snapshot taken by an earlier `loadTasks()`, and a resume that transferred this partition
50
+ * away in the meantime leaves that snapshot describing a record the row no longer holds — acting
51
+ * on it would delete the partition on behalf of an owner that has already released it. `store.js`
52
+ * writes this column only through `serializeOmpSessionOwnership`, whose output is canonical per
53
+ * record, so comparing the stored bytes is an exact "still the same record" test.
54
+ *
55
+ * The recursive removal runs *after* the fence is released, because by then the tree only answers
56
+ * to an unguessable staging name nothing else knows, and holding a write lock across an
57
+ * arbitrarily large `rm -r` would stall every other task-store writer.
58
+ */
59
+ function stageUnderOwnerFence(ownership, taskId) {
60
+ const database = getTaskStoreDatabase();
61
+ const expectedRecord = serializeOmpSessionOwnership(ownership);
62
+ const readRecord = database.prepare(
63
+ 'SELECT omp_session_ownership AS record FROM tasks WHERE id = ?'
64
+ );
65
+ const fenced = database.transaction(() => {
66
+ const row = readRecord.get(taskId);
67
+ if (!row || row.record !== expectedRecord) {
68
+ return {
69
+ staged: false,
70
+ deleted: false,
71
+ reason: 'its ownership record changed while cleanup was running',
72
+ };
73
+ }
74
+ const owners = findAuthoritativeOwnersForPartition(ownership.partitionId, taskId, database);
75
+ if (owners.length > 0) {
76
+ return {
77
+ staged: false,
78
+ deleted: false,
79
+ reason: `it is still claimed by ${owners.map((o) => `${o.taskId} (${o.state})`).join(', ')}`,
80
+ };
81
+ }
82
+ return stageOmpSessionPartitionForDeletion(ownership);
83
+ });
84
+ // BEGIN IMMEDIATE: take the write lock up front so the checks cannot run under a shared lock that
85
+ // another writer is simultaneously upgrading.
86
+ return fenced.immediate();
87
+ }
88
+
89
+ /**
90
+ * Delete one task's OMP session partition. Returns true when the task row is now safe to remove
91
+ * (nothing to clean, or the partition is gone).
92
+ *
93
+ * @param {object} task task record as returned by the store
94
+ * @param {(message: string) => void} warn receives an actionable message for a retained partition
95
+ * @param {{clearRecord?: boolean}} options `clearRecord` NULLs the row's ownership after a
96
+ * successful delete — required on surfaces (cluster clear) where the row itself survives.
97
+ */
98
+ export function cleanupOmpSessionPartitionForTask(task, warn, { clearRecord = false } = {}) {
99
+ if (!task?.ompSessionOwnership) return true;
100
+ const ownership = validateOwnedByTask(task.ompSessionOwnership, task.id);
101
+ if (!ownership) {
102
+ warn(
103
+ `Task ${task.id}: retained an OMP session ownership record that failed validation; nothing was deleted.`
104
+ );
105
+ return false;
106
+ }
107
+
108
+ // More than one row can name a single partition: a resume inserts its provisional row before its
109
+ // ownership transfer runs, and two competing resumes of one committed session put three rows on
110
+ // it. The fence is therefore every *authoritative* (provisional or committed) claim other than
111
+ // this row's — a crashed-before-transfer resume must not delete the prior owner's still-resumable
112
+ // session, and a losing competing resume must not delete the winner's live one.
113
+ const staged = stageUnderOwnerFence(ownership, task.id);
114
+ if (!staged.staged) {
115
+ if (staged.deleted) return finishCleanup(task, clearRecord);
116
+ warn(
117
+ `Task ${task.id}: retained OMP session partition ${ownership.partitionId} (${staged.reason}).`
118
+ );
119
+ return false;
120
+ }
121
+
122
+ const { deleted, reason } = removeStagedOmpSessionPartition(staged.stagingPath);
123
+ if (!deleted) {
124
+ warn(`Task ${task.id}: retained OMP session partition ${ownership.partitionId} (${reason}).`);
125
+ return false;
126
+ }
127
+ return finishCleanup(task, clearRecord);
128
+ }
129
+
130
+ function finishCleanup(task, clearRecord) {
131
+ if (clearRecord) {
132
+ updateTask(task.id, { ompSessionOwnership: null });
133
+ }
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Delete every OMP session partition owned by a cluster's agents. Cluster partitions live under
139
+ * the cluster's own `storageDir`, so this is what makes cluster clear (and therefore purge)
140
+ * actually reclaim them; the task rows themselves survive and have their ownership cleared.
141
+ *
142
+ * @returns {{deleted: string[], retained: string[]}} partition ids
143
+ */
144
+ export function cleanupOmpSessionPartitionsForCluster(clusterId, warn) {
145
+ const deleted = [];
146
+ const retained = [];
147
+ if (!clusterId) return { deleted, retained };
148
+
149
+ for (const task of Object.values(loadTasks())) {
150
+ const ownership = validateOwnedByTask(task?.ompSessionOwnership ?? null, task?.id);
151
+ if (!ownership || ownership.owner.kind !== 'cluster-agent') continue;
152
+ if (ownership.owner.clusterId !== clusterId) continue;
153
+ if (cleanupOmpSessionPartitionForTask(task, warn, { clearRecord: true })) {
154
+ deleted.push(ownership.partitionId);
155
+ } else {
156
+ retained.push(ownership.partitionId);
157
+ }
158
+ }
159
+ return { deleted, retained };
160
+ }
@@ -0,0 +1,262 @@
1
+ // Pure (no DB, no partition I/O) validation/canonicalization for the closed
2
+ // `task.ompSessionOwnership` JSON shape defined by issue #866. Kept dependency-free of
3
+ // task-lib/store.js so store.js can import this module for parse/serialize without a cycle; the
4
+ // DB-touching transitions (provisional -> committed / cleanup-required, owner transfer) live in
5
+ // task-lib/omp-session-ownership.js.
6
+ //
7
+ // The schema is *closed* in both directions: an unknown key anywhere in the object (top level,
8
+ // `owner`, `session`, or either identity) rejects the whole record, and every known key is
9
+ // re-derived into a canonical form on the way out. A record that does not validate is never
10
+ // partially trusted — callers fail closed to a fresh context.
11
+ import { createHash } from 'crypto';
12
+ import { isAbsolute, resolve as resolvePath } from 'path';
13
+ import { createRequire } from 'module';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const { PARTITION_ID_PATTERN, partitionPathFor } = require('../src/omp-session-partition.js');
17
+
18
+ export const OMP_OWNERSHIP_SCHEMA_VERSION = 1;
19
+ export const OMP_OWNERSHIP_STATES = Object.freeze([
20
+ 'provisional',
21
+ 'committed',
22
+ 'cleanup-required',
23
+ ]);
24
+
25
+ const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u;
26
+ const DECIMAL_PATTERN = /^(0|[1-9][0-9]*)$/u;
27
+ const SESSION_FILE_NAME_PATTERN = /^[^/\\]+\.jsonl$/u;
28
+ const STATES = new Set(OMP_OWNERSHIP_STATES);
29
+ const OWNER_KINDS = new Set(['cluster-agent', 'standalone']);
30
+
31
+ const TOP_LEVEL_KEYS = new Set([
32
+ 'schemaVersion',
33
+ 'state',
34
+ 'partitionId',
35
+ 'storageRoot',
36
+ 'partitionPath',
37
+ 'ownerUid',
38
+ 'storageRootIdentity',
39
+ 'partitionIdentity',
40
+ 'canonicalWorkspace',
41
+ 'owner',
42
+ 'session',
43
+ ]);
44
+ const OWNER_KEYS = new Set(['kind', 'clusterId', 'agentId', 'taskId']);
45
+ const SESSION_KEYS = new Set([
46
+ 'sessionId',
47
+ 'fileName',
48
+ 'fileIdentity',
49
+ 'artifactManifestDigest',
50
+ 'executionFingerprint',
51
+ 'selectedProvider',
52
+ 'selectedModel',
53
+ ]);
54
+ const IDENTITY_KEYS = new Set(['device', 'inode']);
55
+
56
+ function hasOnlyKeys(value, allowed) {
57
+ return Object.keys(value).every((key) => allowed.has(key));
58
+ }
59
+
60
+ function isPlainObject(value) {
61
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
62
+ }
63
+
64
+ function isNonEmptyString(value) {
65
+ return typeof value === 'string' && value.length > 0;
66
+ }
67
+
68
+ function isDecimalString(value) {
69
+ return isNonEmptyString(value) && DECIMAL_PATTERN.test(value);
70
+ }
71
+
72
+ function isDigest(value) {
73
+ return isNonEmptyString(value) && SHA256_PATTERN.test(value);
74
+ }
75
+
76
+ /** A canonical absolute path: already fully resolved, so a record can never smuggle in a relative,
77
+ * `..`-bearing, or trailing-separator path that would resolve differently at cleanup time. */
78
+ function isCanonicalAbsolutePath(value) {
79
+ return isNonEmptyString(value) && isAbsolute(value) && resolvePath(value) === value;
80
+ }
81
+
82
+ function normalizeIdentity(value) {
83
+ if (!isPlainObject(value) || !hasOnlyKeys(value, IDENTITY_KEYS)) return null;
84
+ if (!isDecimalString(value.device) || !isDecimalString(value.inode)) return null;
85
+ return { device: value.device, inode: value.inode };
86
+ }
87
+
88
+ export function canonicalOwnerUid() {
89
+ return typeof process.getuid === 'function' ? String(process.getuid()) : '0';
90
+ }
91
+
92
+ /** sha256 over the UTF-8 bytes of a stable JSON encoding (sorted keys) of `fields`. */
93
+ export function computeExecutionFingerprint(fields) {
94
+ const sortedKeys = Object.keys(fields).sort();
95
+ const stable = {};
96
+ for (const key of sortedKeys) stable[key] = fields[key];
97
+ return `sha256:${createHash('sha256').update(JSON.stringify(stable), 'utf8').digest('hex')}`;
98
+ }
99
+
100
+ function normalizeOwner(owner) {
101
+ if (!isPlainObject(owner) || !hasOnlyKeys(owner, OWNER_KEYS)) return null;
102
+ if (!OWNER_KINDS.has(owner.kind)) return null;
103
+ if (!isNonEmptyString(owner.taskId)) return null;
104
+ if (owner.kind === 'cluster-agent') {
105
+ if (!isNonEmptyString(owner.clusterId) || !isNonEmptyString(owner.agentId)) return null;
106
+ } else if (owner.clusterId !== null || owner.agentId !== null) {
107
+ return null;
108
+ }
109
+ return {
110
+ kind: owner.kind,
111
+ clusterId: owner.clusterId,
112
+ agentId: owner.agentId,
113
+ taskId: owner.taskId,
114
+ };
115
+ }
116
+
117
+ function normalizeSession(session) {
118
+ if (!isPlainObject(session) || !hasOnlyKeys(session, SESSION_KEYS)) return null;
119
+ const fileIdentity = normalizeIdentity(session.fileIdentity);
120
+ if (
121
+ !isNonEmptyString(session.sessionId) ||
122
+ !isNonEmptyString(session.fileName) ||
123
+ !SESSION_FILE_NAME_PATTERN.test(session.fileName) ||
124
+ session.fileName === '.jsonl' ||
125
+ !fileIdentity ||
126
+ !isDigest(session.artifactManifestDigest) ||
127
+ !isDigest(session.executionFingerprint) ||
128
+ !isNonEmptyString(session.selectedProvider) ||
129
+ !isNonEmptyString(session.selectedModel)
130
+ ) {
131
+ return null;
132
+ }
133
+ return {
134
+ sessionId: session.sessionId,
135
+ fileName: session.fileName,
136
+ fileIdentity,
137
+ artifactManifestDigest: session.artifactManifestDigest,
138
+ executionFingerprint: session.executionFingerprint,
139
+ selectedProvider: session.selectedProvider,
140
+ selectedModel: session.selectedModel,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Validate and canonicalize an arbitrary value as a closed `task.ompSessionOwnership` object.
146
+ * Returns null on any structural violation.
147
+ */
148
+ export function validateOmpSessionOwnership(value) {
149
+ if (!isPlainObject(value) || !hasOnlyKeys(value, TOP_LEVEL_KEYS)) return null;
150
+ if (value.schemaVersion !== OMP_OWNERSHIP_SCHEMA_VERSION) return null;
151
+ if (!STATES.has(value.state)) return null;
152
+ if (!isNonEmptyString(value.partitionId) || !PARTITION_ID_PATTERN.test(value.partitionId)) {
153
+ return null;
154
+ }
155
+ if (!isCanonicalAbsolutePath(value.storageRoot)) return null;
156
+ if (!isCanonicalAbsolutePath(value.partitionPath)) return null;
157
+ if (!isCanonicalAbsolutePath(value.canonicalWorkspace)) return null;
158
+ // The partition path is fully determined by storageRoot + partitionId. Re-deriving it (instead
159
+ // of trusting the stored string) is what stops a tampered row from pointing cleanup or a resume
160
+ // at an arbitrary directory that merely *looks* canonical.
161
+ let derivedPartitionPath;
162
+ try {
163
+ derivedPartitionPath = partitionPathFor(value.storageRoot, value.partitionId);
164
+ } catch {
165
+ return null;
166
+ }
167
+ if (derivedPartitionPath !== value.partitionPath) return null;
168
+ if (!isDecimalString(value.ownerUid)) return null;
169
+
170
+ const storageRootIdentity = normalizeIdentity(value.storageRootIdentity);
171
+ if (!storageRootIdentity) return null;
172
+
173
+ const owner = normalizeOwner(value.owner);
174
+ if (!owner) return null;
175
+
176
+ if (!Object.hasOwn(value, 'partitionIdentity') || !Object.hasOwn(value, 'session')) return null;
177
+ const hasPartitionIdentity = value.partitionIdentity !== null;
178
+ const hasSession = value.session !== null;
179
+ // No partially populated pairs, in any state: an observation of the materialized session is
180
+ // either complete (both the partition identity and the full session tuple) or absent.
181
+ if (hasPartitionIdentity !== hasSession) return null;
182
+ const partitionIdentity = hasPartitionIdentity ? normalizeIdentity(value.partitionIdentity) : null;
183
+ const session = hasSession ? normalizeSession(value.session) : null;
184
+ if (hasPartitionIdentity && (!partitionIdentity || !session)) return null;
185
+ // `committed` is the only state that asserts a resumable session, so it is the only state that
186
+ // requires the observation to be present.
187
+ if (value.state === 'committed' && !session) return null;
188
+
189
+ return {
190
+ schemaVersion: OMP_OWNERSHIP_SCHEMA_VERSION,
191
+ state: value.state,
192
+ partitionId: value.partitionId,
193
+ storageRoot: value.storageRoot,
194
+ partitionPath: value.partitionPath,
195
+ ownerUid: value.ownerUid,
196
+ storageRootIdentity,
197
+ partitionIdentity,
198
+ canonicalWorkspace: value.canonicalWorkspace,
199
+ owner,
200
+ session,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Validate a record *and* fence it to the task row it was read from: an ownership record whose
206
+ * `owner.taskId` is not this row's id is not this row's ownership, however well-formed it is.
207
+ */
208
+ export function validateOwnedByTask(value, taskId) {
209
+ const validated = validateOmpSessionOwnership(value);
210
+ if (!validated) return null;
211
+ if (!isNonEmptyString(taskId) || validated.owner.taskId !== taskId) return null;
212
+ return validated;
213
+ }
214
+
215
+ /** Build the initial provisional ownership record. Pure — the directory need not exist yet. */
216
+ export function buildProvisionalOwnership({
217
+ partitionId,
218
+ storageRoot,
219
+ storageRootIdentity,
220
+ canonicalWorkspace,
221
+ owner,
222
+ }) {
223
+ const canonicalStorageRoot = resolvePath(storageRoot);
224
+ const record = {
225
+ schemaVersion: OMP_OWNERSHIP_SCHEMA_VERSION,
226
+ state: 'provisional',
227
+ partitionId,
228
+ storageRoot: canonicalStorageRoot,
229
+ partitionPath: partitionPathFor(canonicalStorageRoot, partitionId),
230
+ ownerUid: canonicalOwnerUid(),
231
+ storageRootIdentity,
232
+ partitionIdentity: null,
233
+ canonicalWorkspace: resolvePath(canonicalWorkspace),
234
+ owner,
235
+ session: null,
236
+ };
237
+ const validated = validateOmpSessionOwnership(record);
238
+ if (!validated) {
239
+ throw new Error('buildProvisionalOwnership produced an invalid ownership record.');
240
+ }
241
+ return validated;
242
+ }
243
+
244
+ export function parseOmpSessionOwnership(raw) {
245
+ if (typeof raw !== 'string' || raw === '') return null;
246
+ let parsed;
247
+ try {
248
+ parsed = JSON.parse(raw);
249
+ } catch {
250
+ return null;
251
+ }
252
+ return validateOmpSessionOwnership(parsed);
253
+ }
254
+
255
+ export function serializeOmpSessionOwnership(value) {
256
+ if (!value) return null;
257
+ const validated = validateOmpSessionOwnership(value);
258
+ if (!validated) {
259
+ throw new Error('Refusing to persist an invalid ompSessionOwnership record.');
260
+ }
261
+ return JSON.stringify(validated);
262
+ }