@arcfold/dsh 0.0.0-stage → 0.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/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/cordis.patch.yml +138 -0
- package/package.json +70 -4
- package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
- package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
- package/presets/arcfold.patch.yml +110 -0
- package/runtime/README.md +19 -0
- package/runtime/bootstrap.json +15 -0
- package/runtime/desktop-python.tar.gz +0 -0
- package/runtime/host/package.json +11 -0
- package/runtime/host/pnpm-workspace.yaml +9 -0
- package/runtime/initial-policy.json +94 -0
- package/runtime/profile/pnpm-workspace.yaml +7 -0
- package/scripts/exact-package-patch.mjs +106 -0
- package/scripts/patch-deepseek-transport.mjs +14 -0
- package/src/agent.mjs +56 -0
- package/src/browse-scope.mjs +14 -0
- package/src/budget.mjs +369 -0
- package/src/business.mjs +334 -0
- package/src/context-material.mjs +119 -0
- package/src/deepseek-budget-plugin.mjs +58 -0
- package/src/deepseek-provider.mjs +57 -0
- package/src/deepseek-receipt.mjs +97 -0
- package/src/deepseek-transport.mjs +97 -0
- package/src/desktop/adapter.mjs +16 -0
- package/src/desktop/bootstrap.mjs +84 -0
- package/src/desktop/configuration.mjs +117 -0
- package/src/desktop/provider.mjs +46 -0
- package/src/desktop/python-runtime.mjs +105 -0
- package/src/desktop/setup-service.mjs +81 -0
- package/src/desktop/vendor/LICENSE.deepseek +21 -0
- package/src/desktop/vendor/NOTICE.md +5 -0
- package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
- package/src/evidence-ownership.mjs +51 -0
- package/src/evidence.mjs +35 -0
- package/src/goal-context.mjs +52 -0
- package/src/goal-execution-tool.mjs +74 -0
- package/src/goal-execution.mjs +333 -0
- package/src/goal-tool.mjs +56 -0
- package/src/goal-work-control.mjs +63 -0
- package/src/goal-work.mjs +257 -0
- package/src/goals.mjs +578 -0
- package/src/importer.mjs +298 -0
- package/src/index.mjs +53 -0
- package/src/learning-contracts.mjs +220 -0
- package/src/learning-defaults.mjs +98 -0
- package/src/learning-goals.mjs +165 -0
- package/src/learning-plugin.mjs +28 -0
- package/src/learning-runner.mjs +275 -0
- package/src/learning.mjs +534 -0
- package/src/legacy-export.mjs +332 -0
- package/src/legacy-import-source.mjs +356 -0
- package/src/metered-provider-host.mjs +101 -0
- package/src/metered-transport.mjs +435 -0
- package/src/method-context.mjs +57 -0
- package/src/method-lifecycle.mjs +34 -0
- package/src/method-tool.mjs +61 -0
- package/src/methods.mjs +425 -0
- package/src/model-selection.mjs +41 -0
- package/src/native-context-call.mjs +88 -0
- package/src/native-continuation.mjs +217 -0
- package/src/native-request-authority.mjs +90 -0
- package/src/openai-models.mjs +4 -0
- package/src/openai-receipt.mjs +98 -0
- package/src/openai-transport.mjs +167 -0
- package/src/openai-usage.mjs +194 -0
- package/src/opportunities.mjs +295 -0
- package/src/preparation-artifacts.mjs +213 -0
- package/src/preparation-plugin.mjs +290 -0
- package/src/runtime-command.mjs +264 -0
- package/src/runtime-config.mjs +417 -0
- package/src/runtime-guard.mjs +284 -0
- package/src/runtime-host.mjs +212 -0
- package/src/runtime-init.mjs +278 -0
- package/src/runtime-installation.mjs +518 -0
- package/src/runtime-lifecycle.mjs +227 -0
- package/src/runtime-model-settings.mjs +111 -0
- package/src/runtime-readiness.mjs +285 -0
- package/src/runtime-workspace.mjs +18 -0
- package/src/session-bridge.mjs +365 -0
- package/src/store.mjs +394 -0
- package/src/tree/LICENSE +21 -0
- package/src/tree/attribution.mjs +120 -0
- package/src/tree/clusters.mjs +61 -0
- package/src/tree/consolidation.mjs +103 -0
- package/src/tree/context.mjs +211 -0
- package/src/tree/contracts.mjs +142 -0
- package/src/tree/experience.mjs +140 -0
- package/src/tree/experiment-budget.mjs +84 -0
- package/src/tree/hooks.mjs +254 -0
- package/src/tree/host-context.mjs +107 -0
- package/src/tree/host-process.mjs +288 -0
- package/src/tree/host.mjs +37 -0
- package/src/tree/improvement.mjs +106 -0
- package/src/tree/items.mjs +294 -0
- package/src/tree/jobs.mjs +184 -0
- package/src/tree/learning.mjs +202 -0
- package/src/tree/metrics.mjs +203 -0
- package/src/tree/model-owner.mjs +254 -0
- package/src/tree/observations.mjs +73 -0
- package/src/tree/prompts.mjs +500 -0
- package/src/tree/reports.mjs +67 -0
- package/src/tree/repository.mjs +236 -0
- package/src/tree/runtime.mjs +745 -0
- package/src/tree/script-writer.mjs +149 -0
- package/src/tree/scripts.mjs +208 -0
- package/src/tree/signals.mjs +118 -0
- package/src/tree/snapshots.mjs +379 -0
- package/src/tree/token-counter.mjs +75 -0
- package/src/tree/token-counter.py +37 -0
- package/src/tree/tokenizer-resources/LICENSE +21 -0
- package/src/tree/tokenizer-resources/README.md +31 -0
- package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
- package/src/tree/tokenizer-resources/manifest.json +61 -0
- package/src/tree/tokenizer-resources/native.pyi +1042 -0
- package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
- package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
- package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
- package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
- package/src/tree/tools.mjs +99 -0
- package/src/tree/tree.mjs +202 -0
- package/src/tree/ui.mjs +285 -0
- package/src/tree/verification.mjs +143 -0
- package/src/understandings.mjs +309 -0
- package/src/web-budget-recovery.mjs +181 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { canonicalJson, digest, fail, array, shape, text } from './contracts.mjs';
|
|
2
|
+
import { ReportService } from './items.mjs';
|
|
3
|
+
|
|
4
|
+
const unique = values => [...new Map(values.map(v => [canonicalJson(v), v])).values()];
|
|
5
|
+
export class LocalReports {
|
|
6
|
+
constructor({ repository, resolveSource }) { this.repo = repository; this.resolveSource = resolveSource; }
|
|
7
|
+
recordReview(output, { job, requestId }) {
|
|
8
|
+
const id = digest(['review-only-report', job.id]);
|
|
9
|
+
const roleName = job.role === 'verification' ? '检验' : '改进';
|
|
10
|
+
const category = output.report ? Array.from(output.report.problem).slice(0, 120).join('') : `${roleName}报告`;
|
|
11
|
+
const fingerprint = digest([job.role, job.id]);
|
|
12
|
+
const evidenceRefs = unique(job.input.sources.map(source => source.ref));
|
|
13
|
+
return this.repo.command('recordReviewOnlyReport', { requestId, expected: {}, input: { jobId: job.id, resultHash: digest(output), sourceRefs: evidenceRefs } },
|
|
14
|
+
{ kind: 'model', role: job.role, id: job.id }, () => {
|
|
15
|
+
this.repo.save('tree_reports', { id, category, fingerprint, producerRole: job.trigger.producerRole ?? job.role, step: roleName,
|
|
16
|
+
problem: output.report?.problem ?? '系统已生成一份待核查的报告。', evidenceRefs,
|
|
17
|
+
whyUniversal: output.report?.whyUniversal ?? '这是模型的判断,尚未确认。',
|
|
18
|
+
stopgapIds: [], suggestedFix: output.report?.suggestedFix ?? '查看报告详情和原始依据;报告不会自动改变已有内容或设置。',
|
|
19
|
+
status: 'unhandled', exportAudit: [], reviewOutput: output, reviewJobId: job.id });
|
|
20
|
+
return { reportId: id };
|
|
21
|
+
}).result;
|
|
22
|
+
}
|
|
23
|
+
create(proposal, { job, requestId, evidenceRefs }) {
|
|
24
|
+
const category = proposal.problem.trim(), fingerprint = digest([job.trigger.producerRole, category.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ')]), id = digest(['report', fingerprint]);
|
|
25
|
+
return this.repo.command('recordLocalReport', { requestId, expected: {}, input: { proposal, sourceRefs: evidenceRefs, jobId: job.id } }, { kind: 'model', role: 'improvement', id: job.id }, () => {
|
|
26
|
+
const old = this.repo.get('tree_reports', id);
|
|
27
|
+
this.repo.save('tree_reports', { ...old, id, category, fingerprint, producerRole: job.trigger.producerRole, step: proposal.step, problem: proposal.problem, evidenceRefs: unique([...(old?.evidenceRefs ?? []), ...evidenceRefs]), whyUniversal: proposal.whyUniversal,
|
|
28
|
+
stopgapIds: old?.stopgapIds ?? [], suggestedFix: proposal.suggestedFix, status: old?.status ?? 'unhandled', exportAudit: old?.exportAudit ?? [],
|
|
29
|
+
history: [...(old?.history ?? []), { jobId: job.id, diagnosis: job.validatedResult?.diagnosis ?? '', repairIds: job.input.repairs.map(r => r.id), stopgap: proposal.stopgap, at: this.repo.now() }] }, old?.revision ?? null);
|
|
30
|
+
return { reportId: id };
|
|
31
|
+
}).result;
|
|
32
|
+
}
|
|
33
|
+
preview(id, { includeEvidence = false, actor }) {
|
|
34
|
+
if (actor?.kind !== 'user') fail('FORBIDDEN', 'Only the authenticated owner can preview reports', 403);
|
|
35
|
+
return new ReportService({ repository: this.repo, resolveSource: this.resolveSource, context: () => ({ actor }) }).preview(id, { includeEvidence });
|
|
36
|
+
}
|
|
37
|
+
export(command, actor) {
|
|
38
|
+
if (actor?.kind !== 'user') fail('FORBIDDEN', 'Only an explicit owner action can export', 403);
|
|
39
|
+
return this.repo.command('exportLocalReports', command, actor, () => {
|
|
40
|
+
shape(command.input, ['reportIds', 'includeEvidence']); const ids = array(command.input.reportIds, 'reportIds', 100);
|
|
41
|
+
if (!ids.length || new Set(ids).size !== ids.length || typeof command.input.includeEvidence !== 'boolean') fail('INVALID_FIELD', 'Choose distinct reports and whether evidence is included');
|
|
42
|
+
const content = ids.map(id => { this.repo.requireExpected(command, 'tree_reports', id); return this.preview(id, { includeEvidence: command.input.includeEvidence, actor }); });
|
|
43
|
+
const body = JSON.stringify({ format: 'arcfold.local-reports/v1', reports: content }, null, 2), artifactId = digest(['report-export', command.requestId]);
|
|
44
|
+
for (const id of ids) { const r = this.repo.get('tree_reports', id); this.repo.save('tree_reports', { ...r, exportAudit: [...r.exportAudit, { requestId: command.requestId, artifactId, at: this.repo.now(), includeEvidence: command.input.includeEvidence, contentHash: digest(body) }] }, r.revision); }
|
|
45
|
+
return { artifactId, filename: 'arcfold-reports-' + artifactId.slice(0, 12) + '.json', mimeType: 'application/json', content: body, contentHash: digest(body), includesEvidence: command.input.includeEvidence, sent: false };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
resolved({ fingerprints, releaseId, requestId }) {
|
|
49
|
+
// Only B7's trusted installed-release manifest may call this. No browser or
|
|
50
|
+
// model endpoint accepts a claim that a developer fix has been installed.
|
|
51
|
+
text(releaseId, 'releaseId', 512); array(fingerprints, 'fingerprints', 1000);
|
|
52
|
+
return this.repo.command('resolveInstalledReportFixes', { requestId, expected: {}, input: { fingerprints, releaseId } }, { kind: 'program', id: 'installed-release' }, () => {
|
|
53
|
+
const withdrawn = [], protectedIds = [];
|
|
54
|
+
for (const report of this.repo.list('tree_reports', r => fingerprints.includes(r.fingerprint))) {
|
|
55
|
+
for (const id of report.stopgapIds) {
|
|
56
|
+
const item = this.repo.get('tree_items', id), bundle = item?.activeVersion && this.repo.get('tree_supplements', digest(['supplement', id, item.activeVersion]));
|
|
57
|
+
if (!item || item.lifecycle !== 'active' || !bundle?.universalStopgap || bundle.reportId !== report.id) continue;
|
|
58
|
+
if (item.userProtected) { protectedIds.push(id); continue; }
|
|
59
|
+
this.repo.save('tree_items', { ...item, lifecycle: 'withdrawn', withdrawalReason: 'installed_developer_fix', resolvedByRelease: releaseId }, item.revision); withdrawn.push(id);
|
|
60
|
+
for (const exposure of this.repo.list('tree_exposures', e => !e.revoked && e.itemVersions.some(v => v.itemId === id))) this.repo.save('tree_exposures', { ...exposure, revoked: true, invalidationReason: 'installed_developer_fix' }, exposure.revision);
|
|
61
|
+
}
|
|
62
|
+
this.repo.save('tree_reports', { ...report, status: 'accepted', resolvedByRelease: releaseId }, report.revision);
|
|
63
|
+
}
|
|
64
|
+
return { withdrawn, protectedIds, releaseId };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
3
|
+
import { mkdirSync, existsSync, openSync, readSync, closeSync, chmodSync, realpathSync, statSync, lstatSync, copyFileSync, writeFileSync, fsyncSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve, join } from 'node:path';
|
|
5
|
+
import { SCHEMA, SCHEMA_VERSION, IMMUTABLE_KINDS, canonicalJson, jsonClone, digest, fail, text, integer, shape, validateRecord } from './contracts.mjs';
|
|
6
|
+
const APPLICATION_ID = 0x41544633;
|
|
7
|
+
const TABLES = [
|
|
8
|
+
"CREATE TABLE schema_meta (format TEXT PRIMARY KEY,version INTEGER NOT NULL,user_id TEXT NOT NULL)",
|
|
9
|
+
"CREATE TABLE migrations (version INTEGER PRIMARY KEY,checksum TEXT NOT NULL,applied_at TEXT NOT NULL)",
|
|
10
|
+
"CREATE TABLE records (kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),created_at TEXT NOT NULL,updated_at TEXT NOT NULL,body TEXT NOT NULL CHECK(json_valid(body)),PRIMARY KEY(kind,id))",
|
|
11
|
+
"CREATE UNIQUE INDEX turn_native_identity ON records(json_extract(body,'$.sessionId'),json_extract(body,'$.nativeTurn')) WHERE kind='tree_turns'",
|
|
12
|
+
"CREATE UNIQUE INDEX hook_run_identity ON records(json_extract(body,'$.eventKey'),json_extract(body,'$.hookId'),json_extract(body,'$.version')) WHERE kind='tree_hook_runs'",
|
|
13
|
+
"CREATE UNIQUE INDEX learning_node_identity ON records(json_extract(body,'$.nodeId')) WHERE kind='tree_learning_sessions'",
|
|
14
|
+
];
|
|
15
|
+
const schemaHash = digest(TABLES);
|
|
16
|
+
export const recordKey = (kind, id) => `${kind}:${id}`;
|
|
17
|
+
function inventory(filename) {
|
|
18
|
+
return ['', '-journal'].map(suffix => {
|
|
19
|
+
const path = filename + suffix; if (!existsSync(path)) return { suffix, missing: true };
|
|
20
|
+
const before = lstatSync(path); if (!before.isFile()) fail('RECOVERY_PATH', 'Recovery source must be a regular file');
|
|
21
|
+
const hash = createHash('sha256'), bytes = Buffer.alloc(65536), fd = openSync(path, 'r'); let size;
|
|
22
|
+
try { while ((size = readSync(fd, bytes, 0, bytes.length, null))) hash.update(bytes.subarray(0, size)); } finally { closeSync(fd); }
|
|
23
|
+
const after = lstatSync(path);
|
|
24
|
+
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs) fail('RECOVERY_BUSY', 'Database changed during recovery inspection');
|
|
25
|
+
return { suffix, sha256: hash.digest('hex'), size: after.size, dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function durableJson(filename, data) {
|
|
29
|
+
const fd = openSync(filename, 'wx', 0o600);
|
|
30
|
+
try { writeFileSync(fd, JSON.stringify(data, null, 2) + '\n'); fsyncSync(fd); } finally { closeSync(fd); }
|
|
31
|
+
}
|
|
32
|
+
const recordsHash = db => digest(db.prepare('SELECT kind,id,revision,body FROM records ORDER BY kind,id').all().map(r => ({ ...r })));
|
|
33
|
+
|
|
34
|
+
export class TreeRepository {
|
|
35
|
+
#db; #depth = 0; #changes = new Map(); #closed = false; #nodeIndexes = new Map();
|
|
36
|
+
constructor({ treeFilename, userId, legacyFilename, now = () => new Date().toISOString() }) {
|
|
37
|
+
text(userId, 'userId', 512); text(treeFilename, 'treeFilename', 4096);
|
|
38
|
+
if (treeFilename !== ':memory:' && !treeFilename.startsWith('/')) fail('DATABASE_PATH', 'Explicit absolute treeFilename required');
|
|
39
|
+
this.filename = treeFilename; this.userId = userId; this.now = now; this.metaId = digest(['tree_meta', userId]);
|
|
40
|
+
if (legacyFilename && treeFilename !== ':memory:') {
|
|
41
|
+
if (resolve(legacyFilename) === resolve(treeFilename) || existsSync(treeFilename) && existsSync(legacyFilename) &&
|
|
42
|
+
(realpathSync(treeFilename) === realpathSync(legacyFilename) || statSync(treeFilename).ino === statSync(legacyFilename).ino && statSync(treeFilename).dev === statSync(legacyFilename).dev))
|
|
43
|
+
fail('LEGACY_DATABASE', 'New tree database must not alias the old database');
|
|
44
|
+
}
|
|
45
|
+
const existing = treeFilename !== ':memory:' && existsSync(treeFilename);
|
|
46
|
+
if (existing) {
|
|
47
|
+
const header = Buffer.alloc(100), fd = openSync(treeFilename, 'r'); let bytes;
|
|
48
|
+
try { bytes = readSync(fd, header, 0, 100, 0); } finally { closeSync(fd); }
|
|
49
|
+
if (bytes < 100 || header.subarray(0, 16).toString() !== 'SQLite format 3\0' || header.readUInt32BE(68) !== APPLICATION_ID || header.readUInt32BE(60) !== SCHEMA_VERSION || header[18] !== 1 || header[19] !== 1)
|
|
50
|
+
fail('UNKNOWN_SCHEMA', 'Existing file is not an owned goal-tree database');
|
|
51
|
+
const inspection = new DatabaseSync(treeFilename, { readOnly: true });
|
|
52
|
+
let hotJournal = false;
|
|
53
|
+
try { inspection.exec('PRAGMA busy_timeout=10000'); this.#inspect(inspection); }
|
|
54
|
+
catch (e) { if (e.errcode === 776) hotJournal = true; else throw e; }
|
|
55
|
+
finally { inspection.close(); }
|
|
56
|
+
if (hotJournal) this.recovery = this.#inspectRecoveryCopy();
|
|
57
|
+
} else if (treeFilename !== ':memory:') {
|
|
58
|
+
mkdirSync(dirname(treeFilename), { recursive: true, mode: 0o700 });
|
|
59
|
+
closeSync(openSync(treeFilename, 'wx', 0o600));
|
|
60
|
+
}
|
|
61
|
+
this.#db = new DatabaseSync(treeFilename);
|
|
62
|
+
try {
|
|
63
|
+
this.#db.exec('PRAGMA busy_timeout=10000; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL;');
|
|
64
|
+
if (!existing) this.transaction(() => {
|
|
65
|
+
this.#db.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=${SCHEMA_VERSION}; ${TABLES.join(';')};`);
|
|
66
|
+
this.#db.prepare('INSERT INTO schema_meta VALUES (?,?,?)').run(SCHEMA, SCHEMA_VERSION, userId);
|
|
67
|
+
this.#db.prepare('INSERT INTO migrations VALUES (?,?,?)').run(1, schemaHash, this.now());
|
|
68
|
+
const root = randomUUID();
|
|
69
|
+
this.save('tree_nodes', { id: root, parentId: null, name: '我', status: 'active', statusOrigin: 'user', sourceRefs: [], lastOwnTurnAt: null, lastSummaryWatermark: null });
|
|
70
|
+
this.save('tree_meta', { id: this.metaId, schemaVersion: SCHEMA_VERSION, userId, rootNodeId: root, treeRevision: 1, lastEventOrdinal: 0 });
|
|
71
|
+
});
|
|
72
|
+
this.#inspect(this.#db); this.assertTree();
|
|
73
|
+
if (this.recovery) {
|
|
74
|
+
if (recordsHash(this.#db) !== this.recovery.recordsHash) fail('RECOVERY_CHANGED', 'Recovered original differs from inspected copy; stop and inspect');
|
|
75
|
+
durableJson(join(this.recovery.directory, 'completed.json'), { recordsHash: this.recovery.recordsHash, at: this.now() });
|
|
76
|
+
}
|
|
77
|
+
if (treeFilename !== ':memory:') chmodSync(treeFilename, 0o600);
|
|
78
|
+
} catch (e) { this.#db.close(); throw e; }
|
|
79
|
+
}
|
|
80
|
+
#inspectRecoveryCopy() {
|
|
81
|
+
if (['-wal', '-shm'].some(s => existsSync(this.filename + s))) fail('UNKNOWN_SCHEMA', 'Unexpected journal mode');
|
|
82
|
+
const original = inventory(this.filename), directory = join(dirname(this.filename), `.tree-recovery-${randomUUID()}`), working = join(directory, 'working.sqlite');
|
|
83
|
+
mkdirSync(directory, { mode: 0o700 }); durableJson(join(directory, 'original.json'), { userId: this.userId, files: original });
|
|
84
|
+
for (const file of original.filter(f => !f.missing)) { copyFileSync(this.filename + file.suffix, working + file.suffix); chmodSync(working + file.suffix, 0o600); }
|
|
85
|
+
if (canonicalJson(original) !== canonicalJson(inventory(this.filename))) fail('RECOVERY_BUSY', 'Original changed while copying');
|
|
86
|
+
const copied = inventory(working);
|
|
87
|
+
for (const file of original) { const copy = copied.find(f => f.suffix === file.suffix); if (file.sha256 !== copy.sha256 || file.missing !== copy.missing) fail('RECOVERY_COPY', 'Copy differs from original bytes'); }
|
|
88
|
+
const inspection = new DatabaseSync(working); let hash;
|
|
89
|
+
try {
|
|
90
|
+
inspection.exec('PRAGMA synchronous=FULL; PRAGMA busy_timeout=10000'); this.#inspect(inspection);
|
|
91
|
+
if (inspection.prepare('PRAGMA integrity_check').get().integrity_check !== 'ok') fail('RECOVERY_CORRUPT', 'Recovered copy failed integrity check');
|
|
92
|
+
hash = recordsHash(inspection);
|
|
93
|
+
} finally { inspection.close(); }
|
|
94
|
+
durableJson(join(directory, 'validated.json'), { recordsHash: hash, at: this.now() });
|
|
95
|
+
if (canonicalJson(original) !== canonicalJson(inventory(this.filename))) fail('RECOVERY_BUSY', 'Original changed after copy validation');
|
|
96
|
+
return { directory, recordsHash: hash };
|
|
97
|
+
}
|
|
98
|
+
#inspect(db) {
|
|
99
|
+
const schema = db.prepare('SELECT * FROM schema_meta').all(), migrations = db.prepare('SELECT * FROM migrations ORDER BY version').all();
|
|
100
|
+
if (db.prepare('PRAGMA user_version').get().user_version !== SCHEMA_VERSION || schema.length !== 1 || schema[0].format !== SCHEMA ||
|
|
101
|
+
schema[0].version !== SCHEMA_VERSION || schema[0].user_id !== this.userId || migrations.length !== 1 || migrations[0].version !== 1 ||
|
|
102
|
+
migrations[0].checksum !== schemaHash) fail('UNKNOWN_SCHEMA', 'Unknown schema, owner, or incomplete migration history');
|
|
103
|
+
const sql = db.prepare('SELECT sql FROM sqlite_schema WHERE sql IS NOT NULL').all().map(r => r.sql).sort();
|
|
104
|
+
if (canonicalJson(sql) !== canonicalJson([...TABLES].sort())) fail('UNKNOWN_SCHEMA', 'Unexpected schema definition');
|
|
105
|
+
const tables = db.prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").all().map(r => r.name);
|
|
106
|
+
if (canonicalJson(tables) !== canonicalJson(['migrations', 'records', 'schema_meta'])) fail('UNKNOWN_SCHEMA', 'Unexpected database tables');
|
|
107
|
+
const indexes = db.prepare("SELECT name FROM sqlite_schema WHERE type='index' AND sql IS NOT NULL ORDER BY name").all().map(r => r.name);
|
|
108
|
+
if (canonicalJson(indexes) !== canonicalJson(['hook_run_identity', 'learning_node_identity', 'turn_native_identity'])) fail('UNKNOWN_SCHEMA', 'Incomplete uniqueness constraints');
|
|
109
|
+
}
|
|
110
|
+
transaction(fn) {
|
|
111
|
+
if (this.#closed) fail('STORE_CLOSED', 'Tree repository is closed');
|
|
112
|
+
if (typeof fn !== 'function' || fn.constructor.name === 'AsyncFunction') fail('ASYNC_TRANSACTION', 'Transactions must be synchronous');
|
|
113
|
+
const nested = this.#depth > 0, name = `tree_sp_${this.#depth}`, changes = new Map(this.#changes);
|
|
114
|
+
this.#db.exec(nested ? `SAVEPOINT ${name}` : 'BEGIN IMMEDIATE'); this.#depth++;
|
|
115
|
+
try {
|
|
116
|
+
const result = fn();
|
|
117
|
+
if (result?.then) { Promise.resolve(result).catch(() => {}); fail('ASYNC_TRANSACTION', 'No model, file-copy or network operation inside transactions'); }
|
|
118
|
+
this.#db.exec(nested ? `RELEASE ${name}` : 'COMMIT'); return result;
|
|
119
|
+
} catch (e) { this.#db.exec(nested ? `ROLLBACK TO ${name}; RELEASE ${name}` : 'ROLLBACK'); this.#changes = changes; this.#nodeIndexes.clear(); throw e; }
|
|
120
|
+
finally { this.#depth--; }
|
|
121
|
+
}
|
|
122
|
+
get(kind, id) { const row = this.#db.prepare('SELECT body FROM records WHERE kind=? AND id=?').get(kind, id); return row ? JSON.parse(row.body) : null; }
|
|
123
|
+
list(kind, predicate = () => true) { return this.#db.prepare('SELECT body FROM records WHERE kind=? ORDER BY created_at,id').all(kind).map(r => JSON.parse(r.body)).filter(predicate); }
|
|
124
|
+
listByNodeIds(kind, nodeIds, predicate = () => true) {
|
|
125
|
+
if (!Array.isArray(nodeIds) || nodeIds.some(id => typeof id !== 'string')) fail('INVALID_FIELD', 'Node ids must be strings');
|
|
126
|
+
const ids = [...new Set(nodeIds)];
|
|
127
|
+
if (!ids.length) return [];
|
|
128
|
+
// This derived in-memory index avoids changing the on-disk schema. Own
|
|
129
|
+
// writes maintain it, rollback clears it, and other connections invalidate
|
|
130
|
+
// it via SQLite's data_version. Retry rather than return a mixed snapshot.
|
|
131
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
132
|
+
const version = this.#db.prepare('PRAGMA data_version').get().data_version;
|
|
133
|
+
let index = this.#nodeIndexes.get(kind);
|
|
134
|
+
if (!index || index.version !== version) {
|
|
135
|
+
const nodes = new Map();
|
|
136
|
+
for (const row of this.#db.prepare("SELECT id,json_extract(body,'$.nodeId') AS node_id FROM records WHERE kind=?").all(kind)) {
|
|
137
|
+
if (typeof row.node_id !== 'string') continue;
|
|
138
|
+
if (!nodes.has(row.node_id)) nodes.set(row.node_id, new Set());
|
|
139
|
+
nodes.get(row.node_id).add(row.id);
|
|
140
|
+
}
|
|
141
|
+
index = { version, nodes };
|
|
142
|
+
this.#nodeIndexes.set(kind, index);
|
|
143
|
+
}
|
|
144
|
+
const selected = [...new Set(ids.flatMap(id => [...(index.nodes.get(id) ?? [])]))];
|
|
145
|
+
const rows = [];
|
|
146
|
+
for (let offset = 0; offset < selected.length; offset += 500) {
|
|
147
|
+
const batch = selected.slice(offset, offset + 500);
|
|
148
|
+
const placeholders = batch.map(() => '?').join(',');
|
|
149
|
+
rows.push(...this.#db.prepare(`SELECT body,created_at,id FROM records WHERE kind=? AND id IN (${placeholders})`).all(kind, ...batch));
|
|
150
|
+
}
|
|
151
|
+
if (this.#db.prepare('PRAGMA data_version').get().data_version !== version) { this.#nodeIndexes.delete(kind); continue; }
|
|
152
|
+
rows.sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
153
|
+
return rows.map(row => JSON.parse(row.body)).filter(predicate);
|
|
154
|
+
}
|
|
155
|
+
fail('QUERY_BUSY', 'Records changed repeatedly while reading; retry the read');
|
|
156
|
+
}
|
|
157
|
+
meta() { return this.get('tree_meta', this.metaId); }
|
|
158
|
+
rootId() { return this.meta().rootNodeId; }
|
|
159
|
+
save(kind, record, expectedRevision = null) {
|
|
160
|
+
if (!this.#depth) fail('TRANSACTION_REQUIRED', 'Writes require a domain transaction');
|
|
161
|
+
const copy = jsonClone(record); validateRecord(kind, copy);
|
|
162
|
+
const old = this.get(kind, copy.id);
|
|
163
|
+
if (old && IMMUTABLE_KINDS.has(kind)) fail('IMMUTABLE_RECORD', 'Historical evidence and versions cannot be replaced');
|
|
164
|
+
if (expectedRevision === null ? old !== null : old?.revision !== expectedRevision) fail('REVISION_CONFLICT', 'Record changed');
|
|
165
|
+
const immutableFields = {
|
|
166
|
+
tree_turns: ['sessionId', 'nativeTurn', 'logicalTurnId', 'sourceRefs', 'userSourceRefs', 'startedAt'],
|
|
167
|
+
tree_links: ['turnId', 'nodeId', 'assignmentRevision', 'sourceRefs'],
|
|
168
|
+
tree_signals: ['eventId', 'subjectRefs', 'dimension', 'subitem', 'category', 'polarity', 'evidenceKind', 'sourceRefs', 'confidence', 'correctionOf'],
|
|
169
|
+
tree_script_artifacts: ['scriptId', 'methodId', 'version', 'language', 'entry', 'files', 'argSchema', 'dryRunSupport', 'buildRunId'],
|
|
170
|
+
tree_hooks: ['hookId', 'methodId', 'version', 'moment', 'conditions', 'action', 'message'],
|
|
171
|
+
tree_nodes: ['sourceRefs'],
|
|
172
|
+
}[kind] ?? [];
|
|
173
|
+
if (old && immutableFields.some(k => canonicalJson(old[k] ?? null) !== canonicalJson(copy[k] ?? null))) fail('IMMUTABLE_SOURCE', 'Original source and version content cannot be replaced');
|
|
174
|
+
for (const field of ['reviews', 'exportAudit']) if (old?.[field] && canonicalJson(copy[field]?.slice(0, old[field].length) ?? null) !== canonicalJson(old[field])) fail('IMMUTABLE_HISTORY', 'History must be append-only');
|
|
175
|
+
const at = this.now(); if (new Date(at).toISOString() !== at) fail('CLOCK_INVALID', 'Host UTC timestamp required');
|
|
176
|
+
const saved = { ...copy, revision: old ? old.revision + 1 : 1, createdAt: old?.createdAt ?? at, updatedAt: at };
|
|
177
|
+
this.#db.prepare('INSERT INTO records VALUES (?,?,?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET revision=excluded.revision,updated_at=excluded.updated_at,body=excluded.body')
|
|
178
|
+
.run(kind, saved.id, saved.revision, saved.createdAt, saved.updatedAt, canonicalJson(saved));
|
|
179
|
+
const index = this.#nodeIndexes.get(kind);
|
|
180
|
+
if (index) {
|
|
181
|
+
if (typeof old?.nodeId === 'string') index.nodes.get(old.nodeId)?.delete(saved.id);
|
|
182
|
+
if (typeof saved.nodeId === 'string') {
|
|
183
|
+
if (!index.nodes.has(saved.nodeId)) index.nodes.set(saved.nodeId, new Set());
|
|
184
|
+
index.nodes.get(saved.nodeId).add(saved.id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const key = recordKey(kind, saved.id), previous = this.#changes.get(key);
|
|
188
|
+
this.#changes.set(key, { kind, id: saved.id, before: previous?.before ?? old?.revision ?? 0, after: saved.revision });
|
|
189
|
+
return saved;
|
|
190
|
+
}
|
|
191
|
+
requireExpected(command, kind, id) {
|
|
192
|
+
if (!Object.hasOwn(command.expected, recordKey(kind, id))) fail('EXPECTED_REQUIRED', 'A current version is required for this command');
|
|
193
|
+
}
|
|
194
|
+
command(type, command, actor, mutate) {
|
|
195
|
+
shape(command, ['requestId', 'expected', 'input'], ['actor']); text(command.requestId, 'requestId', 512);
|
|
196
|
+
for (const value of [command.expected, command.input]) if (!value || typeof value !== 'object' || Array.isArray(value)) fail('INVALID_SHAPE', 'Expected and input must be objects', 400);
|
|
197
|
+
jsonClone(command);
|
|
198
|
+
shape(actor, ['kind', 'id'], ['role']); text(actor.id, 'actor id', 512);
|
|
199
|
+
if (!['user', 'model', 'program'].includes(actor.kind) || command.actor && canonicalJson(command.actor) !== canonicalJson(actor)) fail('ACTOR_MISMATCH', 'Actor comes from trusted host context');
|
|
200
|
+
const inputHash = digest({ type, input: command.input, expected: command.expected, actor });
|
|
201
|
+
return this.transaction(() => {
|
|
202
|
+
const receiptId = digest(['receipt', command.requestId]), prior = this.get('tree_receipts', receiptId);
|
|
203
|
+
if (prior) { if (prior.inputHash !== inputHash) fail('IDEMPOTENCY_CONFLICT', 'Request id already identifies different input'); return prior; }
|
|
204
|
+
for (const [key, expected] of Object.entries(command.expected)) {
|
|
205
|
+
integer(expected, 'expected revision'); const split = key.indexOf(':');
|
|
206
|
+
if (split < 1 || this.get(key.slice(0, split), key.slice(split + 1))?.revision !== expected) fail('REVISION_CONFLICT', 'Frozen input changed');
|
|
207
|
+
}
|
|
208
|
+
this.#changes = new Map();
|
|
209
|
+
const result = jsonClone(mutate());
|
|
210
|
+
this.assertTree();
|
|
211
|
+
const meta = this.meta(), ordinal = meta.lastEventOrdinal + 1;
|
|
212
|
+
this.save('tree_meta', { ...meta, lastEventOrdinal: ordinal }, meta.revision);
|
|
213
|
+
const changes = [...this.#changes.values()], eventId = digest(['change', command.requestId]);
|
|
214
|
+
this.save('tree_change_events', { id: eventId, ordinal, commandType: type, actor, entityIds: changes.map(c => recordKey(c.kind, c.id)),
|
|
215
|
+
beforeVersions: changes.map(c => c.before), afterVersions: changes.map(c => c.after), sourceRefs: command.input.sourceRefs ?? [], receiptId });
|
|
216
|
+
return this.save('tree_receipts', { id: receiptId, requestId: command.requestId, inputHash, result,
|
|
217
|
+
revisions: Object.fromEntries(changes.map(c => [recordKey(c.kind, c.id), c.after])), eventIds: [eventId] });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
ancestors(id) {
|
|
221
|
+
const path = [], seen = new Set(); let node = this.get('tree_nodes', id);
|
|
222
|
+
while (node) {
|
|
223
|
+
if (node.deleted || seen.has(node.id)) fail('TREE_CYCLE', 'Invalid tree path'); seen.add(node.id); path.unshift(node);
|
|
224
|
+
if (node.parentId === null) break;
|
|
225
|
+
node = this.get('tree_nodes', node.parentId); if (!node) fail('NODE_NOT_FOUND', 'Missing parent');
|
|
226
|
+
}
|
|
227
|
+
if (!path.length || path[0].id !== this.rootId()) fail('NODE_NOT_FOUND', 'Node is not in this tree'); return path;
|
|
228
|
+
}
|
|
229
|
+
assertTree() {
|
|
230
|
+
const meta = this.meta(); if (!meta) return;
|
|
231
|
+
const nodes = this.list('tree_nodes', n => !n.deleted), roots = nodes.filter(n => n.parentId === null);
|
|
232
|
+
if (roots.length !== 1 || roots[0].id !== meta.rootNodeId || roots[0].status !== 'active') fail('ROOT_INVARIANT', 'One permanent active root required');
|
|
233
|
+
for (const node of nodes) this.ancestors(node.id);
|
|
234
|
+
}
|
|
235
|
+
close() { if (!this.#closed) this.#db.close(); this.#closed = true; }
|
|
236
|
+
}
|