@zhivex-ai/harness 1.4.0-rc.6 → 1.4.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/CHANGELOG.md +31 -0
- package/README.md +124 -71
- package/SECURITY.md +1 -1
- package/SUPPORT.md +2 -2
- package/contracts/engine-api.json +55 -0
- package/contracts/stable-api-signatures.json +33 -33
- package/dist/acp-cli.js +18 -15
- package/dist/cli/arguments.d.ts +3 -0
- package/dist/cli/console/console-budget.d.ts +8 -2
- package/dist/{cli-s1rs15vv.js → cli-2h37qyvg.js} +34 -15
- package/dist/{cli-entry-d5788aay.js → cli-entry-20w6nh0k.js} +359 -21
- package/dist/{cli-entry-9c2k0zwz.js → cli-entry-4ngptr62.js} +2902 -1482
- package/dist/{cli-entry-4k1cf0m3.js → cli-entry-w0hterjs.js} +8 -5
- package/dist/cli.js +2 -2
- package/dist/client/json-contracts.d.ts +95 -2
- package/dist/client/local-service.d.ts +2 -2
- package/dist/client/protocol.d.ts +3 -0
- package/dist/client/stream.d.ts +2 -1
- package/dist/compat/code-support.d.ts +6 -0
- package/dist/compat/code-support.js +29 -6
- package/dist/{config-c002f06f.js → config-j9624t6t.js} +1 -1
- package/dist/{config-j3jga5wh.js → config-qt69hkqj.js} +4 -2
- package/dist/engine/acp.js +14 -12
- package/dist/engine/client.js +11 -10
- package/dist/engine/desktop-providers-v1.js +2 -1
- package/dist/engine/desktop-state-v1.js +9 -9
- package/dist/engine/index.js +48 -46
- package/dist/engine/mcp-stdio-v1.js +7 -6
- package/dist/engine/protocol.js +30 -2
- package/dist/engine/service.js +12 -11
- package/dist/hostile-repository-demo.js +4773 -3027
- package/dist/{index-49ys3s4q.js → index-5jhgatga.js} +35 -16
- package/dist/{index-wq2tax80.js → index-61gthdz6.js} +1 -1
- package/dist/index-6dft2vev.js +343 -0
- package/dist/{index-gq9xagx2.js → index-8zjmnyw5.js} +25 -138
- package/dist/index-937cb7y8.js +705 -0
- package/dist/index-bpc596v3.js +154 -0
- package/dist/index-fm84men1.js +239 -0
- package/dist/{index-e6r04a18.js → index-m34q25jj.js} +1127 -170
- package/dist/index-m49jx72n.js +220 -0
- package/dist/{index-dj5z774k.js → index-m82cfspw.js} +35 -16
- package/dist/{index-qfpz5y2v.js → index-nmjdwd4p.js} +8 -2
- package/dist/{index-0g939pfs.js → index-pdmevfxv.js} +10 -7
- package/dist/{index-4j1h4j8c.js → index-pp474137.js} +1598 -973
- package/dist/{index-2tgb2b4e.js → index-rkbf7dqg.js} +3 -5
- package/dist/index-s2k2edts.js +3778 -0
- package/dist/{index-z6br6qdn.js → index-tfzta544.js} +28 -11
- package/dist/index.js +44 -42
- package/dist/persistence/operations.d.ts +3 -0
- package/dist/providers/vertex-auth.d.ts +5 -1
- package/dist/runtime/config.d.ts +8 -0
- package/dist/runtime/context-runtime.d.ts +1 -0
- package/dist/runtime/error-diagnostics.d.ts +8 -0
- package/dist/runtime/errors.d.ts +10 -0
- package/dist/runtime/harness.d.ts +9 -0
- package/dist/runtime/policy-inspection.d.ts +1 -0
- package/dist/runtime/provider-transient.d.ts +33 -0
- package/dist/runtime/runtime-diagnostics.d.ts +3 -0
- package/dist/runtime/runtime-policy.d.ts +8 -10
- package/dist/runtime/task-acceptance-host.d.ts +1 -1
- package/dist/runtime/task-budget-host.d.ts +217 -0
- package/dist/runtime/task-budget.d.ts +147 -0
- package/dist/runtime/task-continuity.d.ts +434 -0
- package/dist/runtime/task-telemetry.d.ts +63 -0
- package/dist/runtime/task-transport.d.ts +3 -0
- package/dist/runtime/task-usage-context.d.ts +13 -0
- package/dist/runtime/time-to-safe-fix.d.ts +8 -0
- package/dist/runtime/usage-ledger.d.ts +17 -0
- package/dist/service-cli.js +12 -11
- package/dist/{service-client-cli-b52c67hx.js → service-client-cli-fn54ev6w.js} +18 -7
- package/dist/verify-historical-migrations.js +2426 -652
- package/dist/zhx.js +2 -2
- package/docs/CLI.md +38 -8
- package/docs/CLIENT_PROTOCOL.md +9 -1
- package/docs/CODE_RELEASE.md +118 -1
- package/docs/DURABLE_OPERATIONS.md +1 -1
- package/docs/EXTENSIBILITY.md +1 -1
- package/docs/FIRST_USE.md +6 -6
- package/docs/MINIMUM_OFFLINE_EVALS.md +31 -0
- package/docs/MINIMUM_TASK_AUTHORITY.md +395 -0
- package/docs/PROJECT_MEMORY.md +1 -1
- package/docs/README.md +2 -0
- package/docs/STABILITY.md +1 -1
- package/docs/WEB_LIMITS.md +117 -0
- package/evaluations/representative-assembly-matrix.json +28 -1
- package/examples/README.md +5 -5
- package/package.json +8 -5
- package/dist/index-8705zxpr.js +0 -145
- package/dist/index-a1q355va.js +0 -600
- package/dist/index-r0tdj04h.js +0 -976
- package/dist/index-rd0s3gqj.js +0 -1669
- package/dist/index-vtqfjnab.js +0 -1626
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
-
createHarnessClientAdapter
|
|
2
|
+
createHarnessClientAdapter
|
|
3
|
+
} from "./index-937cb7y8.js";
|
|
4
|
+
import {
|
|
3
5
|
harnessPolicyDecisionEventSchema
|
|
4
|
-
} from "./index-
|
|
6
|
+
} from "./index-pp474137.js";
|
|
5
7
|
import {
|
|
6
8
|
openCliSessionStore
|
|
7
|
-
} from "./index-
|
|
9
|
+
} from "./index-s2k2edts.js";
|
|
8
10
|
import {
|
|
9
11
|
SqliteDatabase
|
|
10
|
-
} from "./index-
|
|
12
|
+
} from "./index-m34q25jj.js";
|
|
11
13
|
import {
|
|
12
14
|
harnessClientRequestSchema,
|
|
13
15
|
streamEventDocument
|
|
14
|
-
} from "./index-
|
|
16
|
+
} from "./index-nmjdwd4p.js";
|
|
15
17
|
import {
|
|
16
18
|
__require
|
|
17
19
|
} from "./index-1bx83sw4.js";
|
|
@@ -63,6 +65,14 @@ var openHarnessActivityStore = async (config, options = {}) => {
|
|
|
63
65
|
db.exec(`CREATE TABLE IF NOT EXISTS client_activity_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, session TEXT NOT NULL, run TEXT NOT NULL, at INTEGER NOT NULL, activity TEXT NOT NULL);
|
|
64
66
|
CREATE INDEX IF NOT EXISTS client_activity_scope ON client_activity_events(scope, session, sequence);
|
|
65
67
|
CREATE TABLE IF NOT EXISTS client_activity_snapshots(scope TEXT NOT NULL, session TEXT NOT NULL, sequence INTEGER NOT NULL, expired_through INTEGER NOT NULL DEFAULT 0, snapshot TEXT NOT NULL, PRIMARY KEY(scope,session));`);
|
|
68
|
+
const readSnapshot = db.query("SELECT snapshot,expired_through FROM client_activity_snapshots WHERE scope=? AND session=?");
|
|
69
|
+
const expiredEvents = db.query(`SELECT session,MAX(sequence) AS seq FROM client_activity_events WHERE scope=? AND (at < ? OR sequence NOT IN (SELECT sequence FROM client_activity_events WHERE scope=? ORDER BY sequence DESC LIMIT ?)) GROUP BY session`);
|
|
70
|
+
const markExpired = db.query("UPDATE client_activity_snapshots SET expired_through=MAX(expired_through,?) WHERE scope=? AND session=?");
|
|
71
|
+
const deleteExpired = db.query("DELETE FROM client_activity_events WHERE scope=? AND session=? AND sequence<=?");
|
|
72
|
+
const insertEvent = db.query("INSERT INTO client_activity_events(scope,session,run,at,activity) VALUES(?,?,?,?,?)");
|
|
73
|
+
const lastSequence = db.query("SELECT last_insert_rowid() AS seq");
|
|
74
|
+
const saveSnapshot = db.query("INSERT INTO client_activity_snapshots(scope,session,sequence,snapshot) VALUES(?,?,?,?) ON CONFLICT(scope,session) DO UPDATE SET sequence=excluded.sequence,snapshot=excluded.snapshot");
|
|
75
|
+
const readEvents = db.query("SELECT sequence,session,run,at,activity FROM client_activity_events WHERE scope=? AND session=? AND sequence>? ORDER BY sequence LIMIT 201");
|
|
66
76
|
const redaction = createRedactionPolicy({ includeEmails: true });
|
|
67
77
|
const redact = (text) => {
|
|
68
78
|
let result = text;
|
|
@@ -111,14 +121,14 @@ var openHarnessActivityStore = async (config, options = {}) => {
|
|
|
111
121
|
return result;
|
|
112
122
|
};
|
|
113
123
|
const snapshot = (sessionId) => {
|
|
114
|
-
const row =
|
|
124
|
+
const row = readSnapshot.get(scope, sessionId);
|
|
115
125
|
return { state: row ? JSON.parse(row.snapshot) : { schemaVersion: 1, sessionId, sequence: 0, runs: {} }, expiredThrough: row?.expired_through ?? 0 };
|
|
116
126
|
};
|
|
117
127
|
const prune = () => {
|
|
118
|
-
const expired =
|
|
128
|
+
const expired = expiredEvents.all(scope, now() - retentionMs, scope, maxEvents);
|
|
119
129
|
for (const row of expired) {
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
markExpired.run(row.seq, scope, row.session);
|
|
131
|
+
deleteExpired.run(scope, row.session, row.seq);
|
|
122
132
|
}
|
|
123
133
|
};
|
|
124
134
|
const write = (sessionId, runId, activity) => {
|
|
@@ -160,10 +170,10 @@ var openHarnessActivityStore = async (config, options = {}) => {
|
|
|
160
170
|
if (activity.type === "tool-approval-request" || activity.type === "agent-approval-request")
|
|
161
171
|
state.status = "waiting_approval";
|
|
162
172
|
current.runs[runId] = state;
|
|
163
|
-
|
|
164
|
-
current.sequence =
|
|
173
|
+
insertEvent.run(scope, sessionId, runId, at, encoded);
|
|
174
|
+
current.sequence = lastSequence.get().seq;
|
|
165
175
|
const bounded = serializeBoundedSnapshot(current, runId);
|
|
166
|
-
|
|
176
|
+
saveSnapshot.run(scope, sessionId, current.sequence, bounded);
|
|
167
177
|
prune();
|
|
168
178
|
db.exec("COMMIT");
|
|
169
179
|
} catch (e) {
|
|
@@ -250,7 +260,7 @@ var openHarnessActivityStore = async (config, options = {}) => {
|
|
|
250
260
|
throw new Error("ACTIVITY_CURSOR_AHEAD");
|
|
251
261
|
if (expired)
|
|
252
262
|
return { schemaVersion: 1, cursorExpired: true, hasMore: false, policyEvidenceIncomplete: true, nextCursor: current.state.sequence, events: [], snapshot: current.state };
|
|
253
|
-
const rows =
|
|
263
|
+
const rows = readEvents.all(scope, sessionId, after);
|
|
254
264
|
const events = rows.slice(0, 200).map((row) => ({ schemaVersion: 1, eventId: createHash("sha256").update(`${scope}:${row.sequence}`).digest("hex"), sequence: row.sequence, sessionId: row.session, runId: row.run, at: row.at, activity: JSON.parse(row.activity) }));
|
|
255
265
|
return { schemaVersion: 1, cursorExpired: false, hasMore: rows.length > 200, policyEvidenceIncomplete: current.expiredThrough > 0, nextCursor: events.at(-1)?.sequence ?? after, events };
|
|
256
266
|
},
|
|
@@ -416,9 +426,18 @@ var startHarnessLocalService = async (harness, options) => {
|
|
|
416
426
|
adapter = await createHarnessClientAdapter(harness, {
|
|
417
427
|
...options.approvalNow ? { now: options.approvalNow } : {},
|
|
418
428
|
onPolicyDecision: (sessionId, runId, event) => activity.policyDecision(sessionId, runId, event),
|
|
419
|
-
onPrompt: (sessionId, runId, prompt) =>
|
|
420
|
-
|
|
421
|
-
|
|
429
|
+
onPrompt: async (sessionId, runId, prompt) => {
|
|
430
|
+
await activity.prompt(sessionId, runId, prompt);
|
|
431
|
+
await options.onPrompt?.(sessionId, runId, prompt);
|
|
432
|
+
},
|
|
433
|
+
onEvent: async (sessionId, runId, event) => {
|
|
434
|
+
await activity.append(sessionId, runId, event);
|
|
435
|
+
await options.onEvent?.(sessionId, runId, event);
|
|
436
|
+
},
|
|
437
|
+
onCheckpoint: async (sessionId, runId, status) => {
|
|
438
|
+
await activity.checkpoint(sessionId, runId, status);
|
|
439
|
+
await options.onCheckpoint?.(sessionId, runId, status);
|
|
440
|
+
}
|
|
422
441
|
});
|
|
423
442
|
await new Promise((resolve, reject) => {
|
|
424
443
|
server.once("error", reject);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DEFAULT_PROVIDER_REGISTRY,
|
|
3
3
|
HARNESS_SUBAGENT_PROFILES
|
|
4
|
-
} from "./index-
|
|
4
|
+
} from "./index-8zjmnyw5.js";
|
|
5
5
|
|
|
6
6
|
// src/providers/routing.ts
|
|
7
7
|
var routeError = (value) => new Error(`Invalid route: ${value}. Use <profile>=<provider>[:<model>].`);
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TERMINAL_SESSION_RUN_STATUSES
|
|
3
|
+
} from "./index-s2k2edts.js";
|
|
4
|
+
import {
|
|
5
|
+
SqliteDatabase,
|
|
6
|
+
createEditProposal,
|
|
7
|
+
fileDigestSchema,
|
|
8
|
+
readRegularFileNoFollow,
|
|
9
|
+
statRegularFileNoFollow,
|
|
10
|
+
withWorkspaceMutation,
|
|
11
|
+
workspaceFilePathSchema
|
|
12
|
+
} from "./index-m34q25jj.js";
|
|
13
|
+
|
|
14
|
+
// src/persistence/workspace-checkpoints.ts
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
var fileSchema = z.strictObject({
|
|
19
|
+
path: workspaceFilePathSchema,
|
|
20
|
+
digest: fileDigestSchema,
|
|
21
|
+
content: z.string().max(65536),
|
|
22
|
+
mode: z.number().int().min(0).max(511)
|
|
23
|
+
}).refine((file) => Buffer.byteLength(file.content) <= 65536 && !file.content.includes("\x00") && file.digest === `sha256:${createHash("sha256").update(file.content).digest("hex")}`, "Invalid checkpoint content identity.");
|
|
24
|
+
var workspaceCheckpointSchema = z.strictObject({
|
|
25
|
+
id: z.string().uuid(),
|
|
26
|
+
sessionId: z.string(),
|
|
27
|
+
turnId: z.string(),
|
|
28
|
+
files: z.array(fileSchema).min(1).max(20)
|
|
29
|
+
});
|
|
30
|
+
var workspaceRestoreOperationSchema = z.strictObject({
|
|
31
|
+
id: z.string().uuid(),
|
|
32
|
+
checkpoint: workspaceCheckpointSchema,
|
|
33
|
+
proposalId: fileDigestSchema,
|
|
34
|
+
expected: z.record(z.string(), fileDigestSchema),
|
|
35
|
+
stage: z.enum(["prepared", "forking", "forked", "applying", "applied", "completed"]),
|
|
36
|
+
forkSessionId: z.string().optional()
|
|
37
|
+
});
|
|
38
|
+
var digest = (content) => `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
39
|
+
var retentionSelection = z.object({ checkpointIds: z.array(z.string().uuid()).max(100), completedRestoreIds: z.array(z.string().uuid()).max(100) }).strict().refine((value) => value.checkpointIds.length + value.completedRestoreIds.length > 0 && new Set([...value.checkpointIds, ...value.completedRestoreIds]).size === value.checkpointIds.length + value.completedRestoreIds.length, "Select unique checkpoint and completed restore IDs.");
|
|
40
|
+
var openWorkspaceCheckpointStore = async (workspace, sessions) => {
|
|
41
|
+
const key = createHash("sha256").update("workspace\x00").update(workspace.root).digest("hex");
|
|
42
|
+
if (key !== sessions.workspaceKey)
|
|
43
|
+
throw new Error("Checkpoint session store belongs to a different workspace.");
|
|
44
|
+
const before = await statRegularFileNoFollow(sessions.databasePath, { label: "Checkpoint database", requireSingleLink: true });
|
|
45
|
+
if (before.uid !== process.getuid?.() || before.mode & 63)
|
|
46
|
+
throw new Error("Checkpoint database must be private.");
|
|
47
|
+
const db = new SqliteDatabase(sessions.databasePath, { create: false });
|
|
48
|
+
const after = await statRegularFileNoFollow(sessions.databasePath, { label: "Checkpoint database", requireSingleLink: true });
|
|
49
|
+
if (before.dev !== after.dev || before.ino !== after.ino) {
|
|
50
|
+
db.close();
|
|
51
|
+
throw new Error("Checkpoint database changed.");
|
|
52
|
+
}
|
|
53
|
+
db.exec("CREATE TABLE IF NOT EXISTS zhivex_workspace_checkpoints (id TEXT PRIMARY KEY, workspace_key TEXT NOT NULL, scope_key TEXT NOT NULL, kind TEXT NOT NULL, body TEXT NOT NULL)");
|
|
54
|
+
const save = (kind, value) => {
|
|
55
|
+
const existing = db.query("SELECT id FROM zhivex_workspace_checkpoints WHERE id=?").get(value.id);
|
|
56
|
+
const count = db.query("SELECT COUNT(*) AS count FROM zhivex_workspace_checkpoints WHERE workspace_key=? AND scope_key=?").get(sessions.workspaceKey, sessions.scopeKey)?.count ?? 0;
|
|
57
|
+
if (!existing && count >= 100)
|
|
58
|
+
throw new Error("Checkpoint scope storage limit reached (100 records).");
|
|
59
|
+
const body = JSON.stringify(value);
|
|
60
|
+
if (Buffer.byteLength(body) > 2 * 1024 * 1024)
|
|
61
|
+
throw new Error("Checkpoint exceeds storage limit.");
|
|
62
|
+
db.query("INSERT INTO zhivex_workspace_checkpoints(id, workspace_key, scope_key, kind, body) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET body=excluded.body WHERE workspace_key=excluded.workspace_key AND scope_key=excluded.scope_key AND kind=excluded.kind").run(value.id, sessions.workspaceKey, sessions.scopeKey, kind, body);
|
|
63
|
+
};
|
|
64
|
+
const load = (kind, id) => {
|
|
65
|
+
const row = db.query("SELECT body FROM zhivex_workspace_checkpoints WHERE id=? AND workspace_key=? AND scope_key=? AND kind=?").get(id, sessions.workspaceKey, sessions.scopeKey, kind);
|
|
66
|
+
if (!row)
|
|
67
|
+
throw new Error("Unknown checkpoint or restore operation in this scope.");
|
|
68
|
+
return JSON.parse(row.body);
|
|
69
|
+
};
|
|
70
|
+
const read = async (target) => {
|
|
71
|
+
const authorized = await workspace.readFile(target, 1, 1);
|
|
72
|
+
const { contents, stat } = await readRegularFileNoFollow(path.join(workspace.root, target), {
|
|
73
|
+
label: "Checkpoint target",
|
|
74
|
+
maxBytes: 65536,
|
|
75
|
+
requireSingleLink: true
|
|
76
|
+
});
|
|
77
|
+
const content = new TextDecoder("utf-8", { fatal: true }).decode(contents);
|
|
78
|
+
if (contents.includes(0) || digest(contents) !== authorized.digest)
|
|
79
|
+
throw new Error("Checkpoint file changed while reading.");
|
|
80
|
+
return fileSchema.parse({ path: target, digest: digest(contents), content, mode: stat.mode & 511 });
|
|
81
|
+
};
|
|
82
|
+
const validateTurn = async (checkpoint) => {
|
|
83
|
+
const session = await sessions.get(checkpoint.sessionId);
|
|
84
|
+
const run = session?.runs.find((entry) => entry.turnId === checkpoint.turnId);
|
|
85
|
+
if (!session || !run || !TERMINAL_SESSION_RUN_STATUSES.includes(run.status)) {
|
|
86
|
+
throw new Error("Checkpoint requires an existing terminal conversation turn.");
|
|
87
|
+
}
|
|
88
|
+
return session;
|
|
89
|
+
};
|
|
90
|
+
const proposal = (operation) => {
|
|
91
|
+
if (new Set(operation.checkpoint.files.map((file) => file.path)).size !== operation.checkpoint.files.length || operation.checkpoint.files.some((file) => digest(file.content) !== file.digest))
|
|
92
|
+
throw new Error("Checkpoint content identity is corrupt.");
|
|
93
|
+
const changes = operation.checkpoint.files.map((file) => ({ path: file.path, expectedDigest: operation.expected[file.path], content: file.content }));
|
|
94
|
+
const result = createEditProposal({ changes });
|
|
95
|
+
if (result.proposalId !== operation.proposalId)
|
|
96
|
+
throw new Error("Restore proposal identity is corrupt.");
|
|
97
|
+
return { proposalId: result.proposalId, changes };
|
|
98
|
+
};
|
|
99
|
+
const getOperation = (id) => workspaceRestoreOperationSchema.parse(load("restore", id));
|
|
100
|
+
const list = (kind) => db.query("SELECT body FROM zhivex_workspace_checkpoints WHERE workspace_key=? AND scope_key=? AND kind=? ORDER BY id").all(sessions.workspaceKey, sessions.scopeKey, kind).map((row) => JSON.parse(row.body));
|
|
101
|
+
const summary = (checkpoint) => ({
|
|
102
|
+
id: checkpoint.id,
|
|
103
|
+
sessionId: checkpoint.sessionId,
|
|
104
|
+
turnId: checkpoint.turnId,
|
|
105
|
+
files: checkpoint.files.map((file) => ({
|
|
106
|
+
path: file.path,
|
|
107
|
+
digest: file.digest,
|
|
108
|
+
bytes: Buffer.byteLength(file.content),
|
|
109
|
+
mode: file.mode
|
|
110
|
+
}))
|
|
111
|
+
});
|
|
112
|
+
const inventory = () => db.query("SELECT id,kind,body FROM zhivex_workspace_checkpoints WHERE workspace_key=? AND scope_key=? ORDER BY id").all(sessions.workspaceKey, sessions.scopeKey).map((row) => {
|
|
113
|
+
const value = row.kind === "checkpoint" ? workspaceCheckpointSchema.parse(JSON.parse(row.body)) : row.kind === "restore" ? workspaceRestoreOperationSchema.parse(JSON.parse(row.body)) : undefined;
|
|
114
|
+
if (!value || value.id !== row.id)
|
|
115
|
+
throw new Error("Invalid checkpoint inventory.");
|
|
116
|
+
return { ...row, value };
|
|
117
|
+
});
|
|
118
|
+
const retentionPlan = (input) => {
|
|
119
|
+
const selection = retentionSelection.parse(input);
|
|
120
|
+
const rows = inventory();
|
|
121
|
+
const selected = new Set([...selection.checkpointIds, ...selection.completedRestoreIds]);
|
|
122
|
+
for (const id of selection.checkpointIds) {
|
|
123
|
+
if (!rows.some((row) => row.id === id && row.kind === "checkpoint"))
|
|
124
|
+
throw new Error("Unknown checkpoint selection in this scope.");
|
|
125
|
+
}
|
|
126
|
+
for (const id of selection.completedRestoreIds) {
|
|
127
|
+
const row = rows.find((row2) => row2.id === id && row2.kind === "restore");
|
|
128
|
+
if (!row || !("stage" in row.value) || row.value.stage !== "completed")
|
|
129
|
+
throw new Error("Retention cannot remove pending or unknown restore evidence.");
|
|
130
|
+
}
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
if ("checkpoint" in row.value && selection.checkpointIds.includes(row.value.checkpoint.id) && !selected.has(row.id)) {
|
|
133
|
+
throw new Error("Checkpoint is still referenced by a retained restore operation.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const records = rows.filter((row) => selected.has(row.id)).map((row) => ({
|
|
137
|
+
id: row.id,
|
|
138
|
+
kind: row.kind,
|
|
139
|
+
sessionId: "checkpoint" in row.value ? row.value.checkpoint.sessionId : row.value.sessionId,
|
|
140
|
+
bytes: Buffer.byteLength(row.body)
|
|
141
|
+
}));
|
|
142
|
+
const planId = digest(JSON.stringify({
|
|
143
|
+
workspace: sessions.workspaceKey,
|
|
144
|
+
scope: sessions.scopeKey,
|
|
145
|
+
selection: [...selected].sort(),
|
|
146
|
+
inventory: rows.map((row) => [row.id, row.kind, digest(row.body)])
|
|
147
|
+
}));
|
|
148
|
+
return {
|
|
149
|
+
planId,
|
|
150
|
+
selection: { checkpointIds: [...selection.checkpointIds].sort(), completedRestoreIds: [...selection.completedRestoreIds].sort() },
|
|
151
|
+
records,
|
|
152
|
+
recordsBefore: rows.length,
|
|
153
|
+
recordsAfter: rows.length - records.length,
|
|
154
|
+
bytesRemoved: records.reduce((sum, row) => sum + row.bytes, 0),
|
|
155
|
+
workspaceFilesChanged: false,
|
|
156
|
+
conversationsChanged: false
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
close: () => db.close(),
|
|
161
|
+
getCheckpoint: (id) => workspaceCheckpointSchema.parse(load("checkpoint", id)),
|
|
162
|
+
getOperation,
|
|
163
|
+
storageStatus: () => {
|
|
164
|
+
const rows = inventory();
|
|
165
|
+
const operations = rows.filter((row) => ("stage" in row.value));
|
|
166
|
+
const referenced = new Set(operations.map((row) => row.value.checkpoint.id));
|
|
167
|
+
return {
|
|
168
|
+
policy: "explicit-reviewed-removal",
|
|
169
|
+
automaticEviction: false,
|
|
170
|
+
maxRecords: 100,
|
|
171
|
+
maxRecordBytes: 2 * 1024 * 1024,
|
|
172
|
+
maxFilesPerCheckpoint: 20,
|
|
173
|
+
maxFileBytes: 65536,
|
|
174
|
+
records: rows.length,
|
|
175
|
+
availableRecords: Math.max(0, 100 - rows.length),
|
|
176
|
+
serializedBytes: rows.reduce((sum, row) => sum + Buffer.byteLength(row.body), 0),
|
|
177
|
+
pendingRestores: operations.filter((row) => row.value.stage !== "completed").length,
|
|
178
|
+
checkpoints: rows.filter((row) => row.kind === "checkpoint").map((row) => ({ id: row.id, referenced: referenced.has(row.id) }))
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
prepareRetention: retentionPlan,
|
|
182
|
+
applyRetention: async (selection, reviewedPlanId) => withWorkspaceMutation(workspace.root, async () => {
|
|
183
|
+
db.exec("BEGIN IMMEDIATE");
|
|
184
|
+
try {
|
|
185
|
+
const plan = retentionPlan(selection);
|
|
186
|
+
if (plan.planId !== reviewedPlanId)
|
|
187
|
+
throw new Error("Retention inventory changed or was not reviewed.");
|
|
188
|
+
for (const row of plan.records)
|
|
189
|
+
db.query("DELETE FROM zhivex_workspace_checkpoints WHERE id=? AND workspace_key=? AND scope_key=? AND kind=?").run(row.id, sessions.workspaceKey, sessions.scopeKey, row.kind);
|
|
190
|
+
db.exec("COMMIT");
|
|
191
|
+
return plan;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
db.exec("ROLLBACK");
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}),
|
|
197
|
+
listCheckpoints: (sessionId) => list("checkpoint").map((value) => workspaceCheckpointSchema.parse(value)).filter((checkpoint) => checkpoint.sessionId === sessionId).map(summary),
|
|
198
|
+
listRestores: (sessionId) => list("restore").map((value) => workspaceRestoreOperationSchema.parse(value)).filter((operation) => operation.checkpoint.sessionId === sessionId).map((operation) => ({
|
|
199
|
+
id: operation.id,
|
|
200
|
+
checkpointId: operation.checkpoint.id,
|
|
201
|
+
proposalId: operation.proposalId,
|
|
202
|
+
stage: operation.stage,
|
|
203
|
+
...operation.forkSessionId ? { forkSessionId: operation.forkSessionId } : {}
|
|
204
|
+
})),
|
|
205
|
+
inspectCheckpoint: async (id) => {
|
|
206
|
+
const checkpoint = workspaceCheckpointSchema.parse(load("checkpoint", id));
|
|
207
|
+
const files = await Promise.all(checkpoint.files.map(async (file) => {
|
|
208
|
+
try {
|
|
209
|
+
const current = await read(file.path);
|
|
210
|
+
if (current.mode !== file.mode)
|
|
211
|
+
return { path: file.path, status: "mode-conflict" };
|
|
212
|
+
return {
|
|
213
|
+
path: file.path,
|
|
214
|
+
status: "available",
|
|
215
|
+
before: current.content,
|
|
216
|
+
after: file.content,
|
|
217
|
+
expectedDigest: current.digest,
|
|
218
|
+
afterDigest: file.digest,
|
|
219
|
+
mode: file.mode
|
|
220
|
+
};
|
|
221
|
+
} catch {
|
|
222
|
+
return { path: file.path, status: "unavailable" };
|
|
223
|
+
}
|
|
224
|
+
}));
|
|
225
|
+
return {
|
|
226
|
+
checkpoint: summary(checkpoint),
|
|
227
|
+
files,
|
|
228
|
+
coverage: {
|
|
229
|
+
existingTextOnly: true,
|
|
230
|
+
maxFiles: 20,
|
|
231
|
+
maxFileBytes: 65536,
|
|
232
|
+
creation: false,
|
|
233
|
+
deletion: false,
|
|
234
|
+
binary: false,
|
|
235
|
+
modeRollback: false,
|
|
236
|
+
fullWorkspaceSnapshot: false
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
previewRestore: async (id) => {
|
|
241
|
+
const operation = getOperation(id);
|
|
242
|
+
for (const file of operation.checkpoint.files) {
|
|
243
|
+
if ((await read(file.path)).mode !== file.mode)
|
|
244
|
+
throw new Error(`Restore mode conflict: ${file.path}.`);
|
|
245
|
+
}
|
|
246
|
+
return workspace.previewPatch(proposal(operation));
|
|
247
|
+
},
|
|
248
|
+
capture: async (input) => withWorkspaceMutation(workspace.root, async () => {
|
|
249
|
+
await validateTurn(input);
|
|
250
|
+
if (input.paths.length === 0 || input.paths.length > 20 || new Set(input.paths).size !== input.paths.length)
|
|
251
|
+
throw new Error("Capture requires 1 to 20 unique existing text files.");
|
|
252
|
+
const files = [];
|
|
253
|
+
for (const target of input.paths)
|
|
254
|
+
files.push(await read(target));
|
|
255
|
+
const checkpoint = workspaceCheckpointSchema.parse({ id: randomUUID(), sessionId: input.sessionId, turnId: input.turnId, files });
|
|
256
|
+
save("checkpoint", checkpoint);
|
|
257
|
+
return checkpoint;
|
|
258
|
+
}),
|
|
259
|
+
prepareRestore: async (checkpointId, expectedCurrent) => withWorkspaceMutation(workspace.root, async () => {
|
|
260
|
+
const checkpoint = workspaceCheckpointSchema.parse(load("checkpoint", checkpointId));
|
|
261
|
+
await validateTurn(checkpoint);
|
|
262
|
+
if (Object.keys(expectedCurrent).length !== checkpoint.files.length)
|
|
263
|
+
throw new Error("Provide exact post-agent digests for every captured file.");
|
|
264
|
+
for (const file of checkpoint.files) {
|
|
265
|
+
const current = await read(file.path);
|
|
266
|
+
if (current.digest !== expectedCurrent[file.path] || current.mode !== file.mode)
|
|
267
|
+
throw new Error(`Restore conflicts with current content or mode: ${file.path}.`);
|
|
268
|
+
}
|
|
269
|
+
const changes = checkpoint.files.map((file) => ({ path: file.path, expectedDigest: expectedCurrent[file.path], content: file.content }));
|
|
270
|
+
const preview = createEditProposal({ changes });
|
|
271
|
+
const operation = workspaceRestoreOperationSchema.parse({
|
|
272
|
+
id: randomUUID(),
|
|
273
|
+
checkpoint,
|
|
274
|
+
proposalId: preview.proposalId,
|
|
275
|
+
expected: expectedCurrent,
|
|
276
|
+
stage: "prepared"
|
|
277
|
+
});
|
|
278
|
+
save("restore", operation);
|
|
279
|
+
return { operation, proposal: { ...preview, changes } };
|
|
280
|
+
}),
|
|
281
|
+
applyRestore: async (id, reviewedProposalId) => withWorkspaceMutation(workspace.root, async () => {
|
|
282
|
+
const operation = getOperation(id);
|
|
283
|
+
if (operation.proposalId !== reviewedProposalId)
|
|
284
|
+
throw new Error("Restore requires the reviewed proposal identity.");
|
|
285
|
+
const patch = proposal(operation);
|
|
286
|
+
if (operation.stage === "completed")
|
|
287
|
+
return operation;
|
|
288
|
+
if (operation.stage === "forking")
|
|
289
|
+
throw new Error(`Fork outcome uncertain. Inspect child sessions titled restore:${operation.id} and call recoverFork with the exact child ID; no files changed.`);
|
|
290
|
+
const current = [];
|
|
291
|
+
for (const file of operation.checkpoint.files) {
|
|
292
|
+
const found = await read(file.path);
|
|
293
|
+
if (found.mode !== file.mode)
|
|
294
|
+
throw new Error(`Restore mode conflict: ${file.path}.`);
|
|
295
|
+
current.push(found);
|
|
296
|
+
}
|
|
297
|
+
const beforeApply = current.every((file) => file.digest === operation.expected[file.path]);
|
|
298
|
+
const afterApply = current.every((file) => file.digest === operation.checkpoint.files.find((entry) => entry.path === file.path)?.digest);
|
|
299
|
+
if (["applying", "applied"].includes(operation.stage) && afterApply) {
|
|
300
|
+
operation.stage = "completed";
|
|
301
|
+
save("restore", operation);
|
|
302
|
+
return operation;
|
|
303
|
+
}
|
|
304
|
+
if (!beforeApply || operation.stage === "applied")
|
|
305
|
+
throw new Error("Restore conflicts with subsequent edits or a partially applied filesystem operation; manual recovery required.");
|
|
306
|
+
if (operation.stage === "prepared") {
|
|
307
|
+
const source = await validateTurn(operation.checkpoint);
|
|
308
|
+
operation.stage = "forking";
|
|
309
|
+
save("restore", operation);
|
|
310
|
+
const fork = await sessions.fork(source.sessionId, {
|
|
311
|
+
atTurnId: operation.checkpoint.turnId,
|
|
312
|
+
expectedRevision: source.revision,
|
|
313
|
+
title: `restore:${operation.id}`
|
|
314
|
+
});
|
|
315
|
+
operation.forkSessionId = fork.sessionId;
|
|
316
|
+
operation.stage = "forked";
|
|
317
|
+
save("restore", operation);
|
|
318
|
+
}
|
|
319
|
+
if (!operation.forkSessionId)
|
|
320
|
+
throw new Error("Restore has no recorded conversation fork.");
|
|
321
|
+
operation.stage = "applying";
|
|
322
|
+
save("restore", operation);
|
|
323
|
+
await workspace.applyPatchWithModes(patch, new Map(operation.checkpoint.files.map((file) => [file.path, { beforeMode: file.mode, afterMode: file.mode }])));
|
|
324
|
+
operation.stage = "applied";
|
|
325
|
+
save("restore", operation);
|
|
326
|
+
operation.stage = "completed";
|
|
327
|
+
save("restore", operation);
|
|
328
|
+
return operation;
|
|
329
|
+
}),
|
|
330
|
+
recoverFork: async (id, forkSessionId) => withWorkspaceMutation(workspace.root, async () => {
|
|
331
|
+
const operation = getOperation(id);
|
|
332
|
+
const fork = await sessions.get(forkSessionId);
|
|
333
|
+
if (operation.stage !== "forking" || !fork || fork.parentSessionId !== operation.checkpoint.sessionId || fork.forkedFromTurnId !== operation.checkpoint.turnId || fork.title !== `restore:${operation.id}`)
|
|
334
|
+
throw new Error("Fork does not match this interrupted restore.");
|
|
335
|
+
operation.forkSessionId = forkSessionId;
|
|
336
|
+
operation.stage = "forked";
|
|
337
|
+
save("restore", operation);
|
|
338
|
+
return operation;
|
|
339
|
+
})
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export { workspaceCheckpointSchema, workspaceRestoreOperationSchema, openWorkspaceCheckpointStore };
|