@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
package/src/store.mjs
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync, chmodSync, existsSync, openSync, closeSync, readSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export class DomainError extends Error {
|
|
6
|
+
constructor(code, message, status = 409, details) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'DomainError';
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.status = this.statusCode = status;
|
|
11
|
+
if (details !== undefined) this.details = details;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Preserve the 3.0 JSON contract without its job, lease or execution machinery.
|
|
16
|
+
export function jsonClone(value) {
|
|
17
|
+
const ancestors = new Set();
|
|
18
|
+
function visit(item) {
|
|
19
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean') return;
|
|
20
|
+
if (typeof item === 'number' && Number.isFinite(item)) return;
|
|
21
|
+
if (typeof item !== 'object' || ancestors.has(item))
|
|
22
|
+
throw new DomainError('invalid_record', 'Only finite, acyclic JSON values can be saved.', 400);
|
|
23
|
+
const prototype = Object.getPrototypeOf(item);
|
|
24
|
+
if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null)
|
|
25
|
+
throw new DomainError('invalid_record', 'Only plain JSON objects can be saved.', 400);
|
|
26
|
+
if (Reflect.ownKeys(item).some(key => typeof key === 'symbol'))
|
|
27
|
+
throw new DomainError('invalid_record', 'Symbol keys cannot be saved.', 400);
|
|
28
|
+
ancestors.add(item);
|
|
29
|
+
if (Array.isArray(item)) {
|
|
30
|
+
for (let i = 0; i < item.length; i++) {
|
|
31
|
+
if (!Object.hasOwn(item, i)) throw new DomainError('invalid_record', 'Sparse arrays cannot be saved.', 400);
|
|
32
|
+
visit(item[i]);
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(item))) {
|
|
36
|
+
if (!Object.hasOwn(descriptor, 'value')) throw new DomainError('invalid_record', 'Accessors cannot be saved.', 400);
|
|
37
|
+
visit(descriptor.value);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
ancestors.delete(item);
|
|
41
|
+
}
|
|
42
|
+
visit(value);
|
|
43
|
+
return JSON.parse(JSON.stringify(value));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function canonicalJson(value) {
|
|
47
|
+
const item = jsonClone(value);
|
|
48
|
+
function sorted(value) {
|
|
49
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
50
|
+
if (value && typeof value === 'object')
|
|
51
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, sorted(value[key])]));
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
return JSON.stringify(sorted(item));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function identity(value, label) {
|
|
58
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 512 || value.includes('\0'))
|
|
59
|
+
throw new DomainError('invalid_identity', `${label} must be a nonempty string of at most 512 characters.`, 400);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function filters(where = {}) {
|
|
63
|
+
if (!where || typeof where !== 'object' || Array.isArray(where) || Object.getPrototypeOf(where) !== Object.prototype)
|
|
64
|
+
throw new DomainError('invalid_filter', 'Filters must be a plain object.', 400);
|
|
65
|
+
const clauses = [], parameters = [];
|
|
66
|
+
for (const [field, value] of Object.entries(where)) {
|
|
67
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(field) ||
|
|
68
|
+
(!['string', 'number', 'boolean'].includes(typeof value) && value !== null) ||
|
|
69
|
+
(typeof value === 'number' && !Number.isFinite(value)))
|
|
70
|
+
throw new DomainError('invalid_filter', 'Filters require safe field names and scalar values.', 400);
|
|
71
|
+
clauses.push('json_extract(body,?) IS ?');
|
|
72
|
+
parameters.push('$.' + field, typeof value === 'boolean' ? Number(value) : value);
|
|
73
|
+
}
|
|
74
|
+
return { sql: clauses.length ? ' AND ' + clauses.join(' AND ') : '', parameters };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const IMMUTABLE = new Set(['dsh_events', 'events', 'deliveries']);
|
|
78
|
+
const WEB_BUDGET_MODE = 'web-only-budget-v1';
|
|
79
|
+
const WEB_BUDGET_APPLICATION_ID = 0x41574631; // AWF1: format discriminator, never authorization.
|
|
80
|
+
const WEB_TABLES = {
|
|
81
|
+
records: `CREATE TABLE records (
|
|
82
|
+
kind TEXT NOT NULL, id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision > 0),
|
|
83
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
84
|
+
body TEXT NOT NULL CHECK(json_valid(body)), PRIMARY KEY(kind,id)
|
|
85
|
+
)`,
|
|
86
|
+
store_meta: 'CREATE TABLE store_meta (key TEXT PRIMARY KEY,value TEXT NOT NULL)',
|
|
87
|
+
budget_requests: `CREATE TABLE budget_requests (
|
|
88
|
+
id TEXT PRIMARY KEY, body_hash TEXT NOT NULL,
|
|
89
|
+
status TEXT NOT NULL CHECK(status IN ('reserved','dispatched','unknown','settled','released')),
|
|
90
|
+
reserved_micro INTEGER NOT NULL CHECK(reserved_micro>=0),
|
|
91
|
+
spent_micro INTEGER NOT NULL CHECK(spent_micro>=0),
|
|
92
|
+
body TEXT NOT NULL CHECK(json_valid(body)),
|
|
93
|
+
scope TEXT NOT NULL DEFAULT 'web-unlimited' CHECK(scope='web-unlimited')
|
|
94
|
+
)`,
|
|
95
|
+
};
|
|
96
|
+
const normalizedSql = value => value.replace(/\s+/g, ' ').trim();
|
|
97
|
+
|
|
98
|
+
// Read only the application-owned format field, without opening SQLite. An
|
|
99
|
+
// absent/zero/different id does not prove ownership and retains old-mode behavior.
|
|
100
|
+
function fileApplicationId(filename) {
|
|
101
|
+
const bytes = Buffer.alloc(4), descriptor = openSync(filename, 'r');
|
|
102
|
+
try { return readSync(descriptor, bytes, 0, 4, 68) === 4 ? bytes.readUInt32BE(0) : null; }
|
|
103
|
+
finally { closeSync(descriptor); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Pure web ledgers use rollback journaling. Refuse other/unfinished files before
|
|
107
|
+
// SQLite could recover a journal or create WAL sidecars while inspecting them.
|
|
108
|
+
export function assertWebBudgetFileFormat(filename) {
|
|
109
|
+
const header = Buffer.alloc(20), descriptor = openSync(filename, 'r');
|
|
110
|
+
let count;
|
|
111
|
+
try { count = readSync(descriptor, header, 0, header.length, 0); }
|
|
112
|
+
finally { closeSync(descriptor); }
|
|
113
|
+
if (count !== 20 || header.subarray(0, 16).toString() !== 'SQLite format 3\0' ||
|
|
114
|
+
header[18] !== 1 || header[19] !== 1 || fileApplicationId(filename) !== WEB_BUDGET_APPLICATION_ID)
|
|
115
|
+
throw new DomainError('unsupported_ledger_schema', 'An intact, idle web-only ledger is required.', 409);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertWebFile(filename) {
|
|
119
|
+
assertWebBudgetFileFormat(filename);
|
|
120
|
+
if (['-journal', '-wal', '-shm'].some(suffix => existsSync(filename + suffix)))
|
|
121
|
+
throw new DomainError('unsupported_ledger_schema', 'An intact, idle web-only ledger is required.', 409);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** JSON business records; this store never claims or runs DSH work. */
|
|
125
|
+
export class Store {
|
|
126
|
+
// Internal owned-recovery handoff: never a configuration bypass. Every
|
|
127
|
+
// supplied connection is validated; the caller retains it on success.
|
|
128
|
+
static fromWebBudgetConnection(db, { filename, webBudgetIdentity }) {
|
|
129
|
+
if (!(db instanceof DatabaseSync)) throw new DomainError('invalid_connection', 'A native SQLite connection is required.');
|
|
130
|
+
const store = Object.create(Store.prototype);
|
|
131
|
+
Object.assign(store, { db, filename: resolve(filename), webBudget: true, legacyLedger: false,
|
|
132
|
+
legacyRecords: true, metaTable: 'store_meta', closed: false, depth: 0, savepointSequence: 0 });
|
|
133
|
+
const identity = jsonClone(webBudgetIdentity);
|
|
134
|
+
if (!identity || Object.keys(identity).sort().join(',') !== 'id,specification' ||
|
|
135
|
+
identity.specification?.scope !== 'web-unlimited')
|
|
136
|
+
throw new DomainError('invalid_web_authorization', 'An explicit web budget identity is required.');
|
|
137
|
+
store.webBudgetIdentity = Object.freeze({ id: identity.id, specification: Object.freeze(identity.specification) });
|
|
138
|
+
const version = db.prepare('SELECT sqlite_version() AS version').get().version.split('.').map(Number);
|
|
139
|
+
if (version[0] < 3 || version[0] === 3 && (version[1] < 51 || version[1] === 51 && version[2] < 3))
|
|
140
|
+
throw new DomainError('unsupported_sqlite', 'Ledger accounting requires SQLite >= 3.51.3.', 500);
|
|
141
|
+
store.assertWebBudgetSchema();
|
|
142
|
+
return store;
|
|
143
|
+
}
|
|
144
|
+
constructor(filename, { mode = 'business', create = false, webBudgetIdentity } = {}) {
|
|
145
|
+
if (typeof filename !== 'string' || !filename)
|
|
146
|
+
throw new DomainError('invalid_path', 'A database filename is required.', 400);
|
|
147
|
+
if (!['business', 'legacy-ledger-v2', 'legacy-records-v2', WEB_BUDGET_MODE].includes(mode) || typeof create !== 'boolean')
|
|
148
|
+
throw new DomainError('invalid_store_mode', 'Unsupported store mode.', 400);
|
|
149
|
+
this.webBudget = mode === WEB_BUDGET_MODE;
|
|
150
|
+
if (this.webBudget) {
|
|
151
|
+
const expected = jsonClone(webBudgetIdentity);
|
|
152
|
+
if (!expected || Object.keys(expected).sort().join(',') !== 'id,specification' ||
|
|
153
|
+
expected.specification?.scope !== 'web-unlimited')
|
|
154
|
+
throw new DomainError('invalid_web_authorization', 'An explicit web budget identity is required.', 400);
|
|
155
|
+
identity(expected.id, 'Web authorization id');
|
|
156
|
+
this.webBudgetIdentity = Object.freeze({ id: expected.id, specification: Object.freeze(expected.specification) });
|
|
157
|
+
}
|
|
158
|
+
this.legacyLedger = mode === 'legacy-ledger-v2';
|
|
159
|
+
this.legacyRecords = this.webBudget || this.legacyLedger || mode === 'legacy-records-v2';
|
|
160
|
+
this.metaTable = this.legacyRecords ? 'store_meta' : 'business_meta';
|
|
161
|
+
this.filename = filename === ':memory:' ? filename : resolve(filename);
|
|
162
|
+
let existed = this.filename !== ':memory:' && existsSync(this.filename);
|
|
163
|
+
let newWebFile = this.filename === ':memory:' && create;
|
|
164
|
+
if (this.webBudget && this.filename !== ':memory:' && create) {
|
|
165
|
+
mkdirSync(dirname(this.filename), { recursive: true, mode: 0o700 });
|
|
166
|
+
try {
|
|
167
|
+
const descriptor = openSync(this.filename, 'wx', 0o600);
|
|
168
|
+
closeSync(descriptor); newWebFile = true; existed = false;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error.code !== 'EEXIST') throw error;
|
|
171
|
+
existed = true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// A committed web format must be rejected by every other mode before any
|
|
175
|
+
// SQLite handle/read can trigger recovery of the original hot journal.
|
|
176
|
+
if (!this.webBudget && existed && fileApplicationId(this.filename) === WEB_BUDGET_APPLICATION_ID)
|
|
177
|
+
throw new DomainError('invalid_store_mode', 'A web-only ledger requires its explicit mode and authorization.', 409);
|
|
178
|
+
if (this.webBudget && existed) assertWebFile(this.filename);
|
|
179
|
+
if (this.legacyRecords && !existed && !create)
|
|
180
|
+
throw new DomainError('ledger_missing', 'An existing authorized ledger is required.', 409);
|
|
181
|
+
if (this.filename !== ':memory:') mkdirSync(dirname(this.filename), { recursive: true, mode: 0o700 });
|
|
182
|
+
this.db = new DatabaseSync(this.filename);
|
|
183
|
+
this.closed = false;
|
|
184
|
+
this.depth = 0;
|
|
185
|
+
this.savepointSequence = 0;
|
|
186
|
+
try {
|
|
187
|
+
if (this.webBudget) {
|
|
188
|
+
this.db.exec('PRAGMA busy_timeout=30000; PRAGMA synchronous=FULL');
|
|
189
|
+
const version = this.db.prepare('SELECT sqlite_version() AS version').get().version.split('.').map(Number);
|
|
190
|
+
if (version[0] < 3 || version[0] === 3 && (version[1] < 51 || version[1] === 51 && version[2] < 3))
|
|
191
|
+
throw new DomainError('unsupported_sqlite', 'Ledger accounting requires SQLite >= 3.51.3.', 500);
|
|
192
|
+
this.transaction(() => {
|
|
193
|
+
if (newWebFile) {
|
|
194
|
+
if (this.db.prepare('SELECT 1 FROM sqlite_schema LIMIT 1').get())
|
|
195
|
+
throw new DomainError('unsupported_ledger_schema', 'The exclusively created file must be empty.', 409);
|
|
196
|
+
this.db.exec('PRAGMA main.application_id=' + WEB_BUDGET_APPLICATION_ID);
|
|
197
|
+
for (const sql of Object.values(WEB_TABLES)) this.db.exec(sql);
|
|
198
|
+
this.db.exec("CREATE INDEX records_by_kind_time ON records(kind,created_at,id); INSERT INTO store_meta VALUES ('schema_version','2'),('mutation_sequence','0')");
|
|
199
|
+
this.db.prepare('INSERT INTO store_meta VALUES (?,?)').run('web_budget_mode',
|
|
200
|
+
canonicalJson({ mode: WEB_BUDGET_MODE, authorization: this.webBudgetIdentity }));
|
|
201
|
+
this.save('budget_authorizations', { ...this.webBudgetIdentity, appliedAt: new Date().toISOString() });
|
|
202
|
+
}
|
|
203
|
+
this.assertWebBudgetSchema();
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Other Store modes must not mutate a separately authorized web ledger.
|
|
208
|
+
if (this.db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name='store_meta'").get() &&
|
|
209
|
+
this.db.prepare("SELECT 1 FROM store_meta WHERE key='web_budget_mode'").get())
|
|
210
|
+
throw new DomainError('invalid_store_mode', 'A web-only ledger requires its explicit mode and authorization.', 409);
|
|
211
|
+
// Validate before any schema or journal write. Never re-establish old order
|
|
212
|
+
// from rowid, and never create business_meta beside the legacy counter.
|
|
213
|
+
if (this.legacyRecords && existed) {
|
|
214
|
+
for (const table of ['records', 'store_meta', ...(this.legacyLedger ? ['budget_settings', 'budget_requests'] : [])]) {
|
|
215
|
+
if (!this.db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?").get(table))
|
|
216
|
+
throw new DomainError('unsupported_ledger_schema', 'The existing ledger is incomplete.', 409);
|
|
217
|
+
}
|
|
218
|
+
if (this.db.prepare("SELECT value FROM store_meta WHERE key='schema_version'").get()?.value !== '2')
|
|
219
|
+
throw new DomainError('unsupported_ledger_schema', 'Only the established v2 ledger is supported.', 409);
|
|
220
|
+
const sequence = this.currentSequence();
|
|
221
|
+
for (const row of this.db.prepare('SELECT body FROM records').all()) {
|
|
222
|
+
const value = JSON.parse(row.body);
|
|
223
|
+
for (const field of ['createdSequence', 'updatedSequence']) {
|
|
224
|
+
if (!Number.isSafeInteger(value[field]) || value[field] < 1 || value[field] > sequence)
|
|
225
|
+
throw new DomainError('invalid_sequence', 'Existing records exceed the established ledger order.', 409);
|
|
226
|
+
}
|
|
227
|
+
if (value.createdSequence > value.updatedSequence)
|
|
228
|
+
throw new DomainError('invalid_sequence', 'Existing record order is inconsistent.', 409);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (this.legacyRecords) {
|
|
232
|
+
const version = this.db.prepare('SELECT sqlite_version() AS version').get().version.split('.').map(Number);
|
|
233
|
+
if (version[0] < 3 || version[0] === 3 && (version[1] < 51 || version[1] === 51 && version[2] < 3))
|
|
234
|
+
throw new DomainError('unsupported_sqlite', 'Ledger accounting requires SQLite >= 3.51.3.', 500);
|
|
235
|
+
}
|
|
236
|
+
this.db.exec(`
|
|
237
|
+
PRAGMA busy_timeout=30000;
|
|
238
|
+
PRAGMA journal_mode=WAL;
|
|
239
|
+
PRAGMA synchronous=FULL;
|
|
240
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
241
|
+
kind TEXT NOT NULL, id TEXT NOT NULL,
|
|
242
|
+
revision INTEGER NOT NULL CHECK(revision > 0),
|
|
243
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
244
|
+
body TEXT NOT NULL CHECK(json_valid(body)), PRIMARY KEY(kind,id)
|
|
245
|
+
);
|
|
246
|
+
CREATE INDEX IF NOT EXISTS records_by_kind_time ON records(kind,created_at,id);
|
|
247
|
+
CREATE TABLE IF NOT EXISTS ${this.metaTable} (key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
|
248
|
+
INSERT OR IGNORE INTO ${this.metaTable} VALUES ('schema_version','${this.legacyRecords ? '2' : '1'}');
|
|
249
|
+
INSERT OR IGNORE INTO ${this.metaTable} VALUES ('mutation_sequence','0');
|
|
250
|
+
`);
|
|
251
|
+
if (this.db.prepare(`SELECT value FROM ${this.metaTable} WHERE key='schema_version'`).get().value !== (this.legacyRecords ? '2' : '1'))
|
|
252
|
+
throw new DomainError('unsupported_store_schema', 'Unsupported business store schema.', 500);
|
|
253
|
+
this.currentSequence();
|
|
254
|
+
if (this.filename !== ':memory:') chmodSync(this.filename, 0o600);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
this.db.close(); this.closed = true; throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
assertWebBudgetIdentity() {
|
|
261
|
+
this.assertOpen();
|
|
262
|
+
const expected = this.webBudgetIdentity;
|
|
263
|
+
if (this.db.prepare('PRAGMA main.application_id').get().application_id !== WEB_BUDGET_APPLICATION_ID)
|
|
264
|
+
throw new DomainError('web_budget_identity_conflict', 'The web ledger format does not match.', 409);
|
|
265
|
+
const marker = this.db.prepare("SELECT value FROM store_meta WHERE key='web_budget_mode'").get()?.value;
|
|
266
|
+
if (!expected || marker !== canonicalJson({ mode: WEB_BUDGET_MODE, authorization: expected }))
|
|
267
|
+
throw new DomainError('web_budget_identity_conflict', 'The declared web ledger identity does not match.', 409);
|
|
268
|
+
const authority = this.get('budget_authorizations', expected.id);
|
|
269
|
+
if (!authority || canonicalJson(authority.specification) !== canonicalJson(expected.specification))
|
|
270
|
+
throw new DomainError('web_authorization_conflict', 'The explicit web authorization does not match.', 409);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
assertWebBudgetSchema() {
|
|
274
|
+
const schema = this.db.prepare("SELECT name,sql FROM sqlite_schema WHERE type IN ('table','view','trigger') ORDER BY name").all();
|
|
275
|
+
if (schema.length !== Object.keys(WEB_TABLES).length ||
|
|
276
|
+
schema.some(row => !WEB_TABLES[row.name] || normalizedSql(row.sql) !== normalizedSql(WEB_TABLES[row.name])) ||
|
|
277
|
+
this.db.prepare("SELECT value FROM store_meta WHERE key='schema_version'").get()?.value !== '2')
|
|
278
|
+
throw new DomainError('unsupported_ledger_schema', 'Only the explicit web-only ledger schema is supported.', 409);
|
|
279
|
+
this.assertWebBudgetIdentity();
|
|
280
|
+
const sequence = this.currentSequence();
|
|
281
|
+
for (const row of this.db.prepare('SELECT kind,id,revision,body FROM records').all()) {
|
|
282
|
+
const value = JSON.parse(row.body);
|
|
283
|
+
if (row.kind !== 'budget_authorizations' || row.id !== this.webBudgetIdentity.id ||
|
|
284
|
+
value.id !== row.id || value.revision !== row.revision)
|
|
285
|
+
throw new DomainError('unsupported_ledger_schema', 'Unexpected web authorization records.', 409);
|
|
286
|
+
if (['createdSequence', 'updatedSequence'].some(field =>
|
|
287
|
+
!Number.isSafeInteger(value[field]) || value[field] < 1 || value[field] > sequence) ||
|
|
288
|
+
value.createdSequence > value.updatedSequence)
|
|
289
|
+
throw new DomainError('invalid_sequence', 'Existing authorization order is inconsistent.', 409);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
assertOpen() {
|
|
294
|
+
if (this.closed) throw new DomainError('store_closed', 'The store is closed.', 503);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
currentSequence() {
|
|
298
|
+
this.assertOpen();
|
|
299
|
+
const raw = this.db.prepare(`SELECT value FROM ${this.metaTable} WHERE key='mutation_sequence'`).get()?.value;
|
|
300
|
+
const value = Number(raw);
|
|
301
|
+
if (raw === undefined || !Number.isSafeInteger(value) || value < 0)
|
|
302
|
+
throw new DomainError('invalid_sequence', 'The mutation sequence is invalid.', 500);
|
|
303
|
+
return value;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
transaction(fn) {
|
|
307
|
+
this.assertOpen();
|
|
308
|
+
if (typeof fn !== 'function' || fn.constructor?.name === 'AsyncFunction')
|
|
309
|
+
throw new DomainError('async_transaction', 'Transactions require a synchronous callback.', 400);
|
|
310
|
+
const nested = this.depth > 0, savepoint = `business_sp_${++this.savepointSequence}`;
|
|
311
|
+
this.db.exec(nested ? `SAVEPOINT ${savepoint}` : 'BEGIN IMMEDIATE');
|
|
312
|
+
this.depth++;
|
|
313
|
+
try {
|
|
314
|
+
const result = fn();
|
|
315
|
+
if (result && typeof result.then === 'function') {
|
|
316
|
+
Promise.resolve(result).catch(() => {});
|
|
317
|
+
throw new DomainError('async_transaction', 'Transactions cannot return a Promise.', 400);
|
|
318
|
+
}
|
|
319
|
+
this.db.exec(nested ? `RELEASE SAVEPOINT ${savepoint}` : 'COMMIT');
|
|
320
|
+
return result;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
try { this.db.exec(nested ? `ROLLBACK TO SAVEPOINT ${savepoint}; RELEASE SAVEPOINT ${savepoint}` : 'ROLLBACK'); }
|
|
323
|
+
catch (rollbackError) { error.rollbackError = rollbackError.message; }
|
|
324
|
+
throw error;
|
|
325
|
+
} finally { this.depth--; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
get(kind, id) {
|
|
329
|
+
this.assertOpen(); identity(kind, 'Record kind'); identity(id, 'Record id');
|
|
330
|
+
const row = this.db.prepare('SELECT body FROM records WHERE kind=? AND id=?').get(kind, id);
|
|
331
|
+
return row ? JSON.parse(row.body) : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
list(kind, { limit = 1000, offset = 0, order = 'asc', where = {} } = {}) {
|
|
335
|
+
this.assertOpen(); identity(kind, 'Record kind');
|
|
336
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > 100000 ||
|
|
337
|
+
!Number.isSafeInteger(offset) || offset < 0 || !['asc', 'desc'].includes(order))
|
|
338
|
+
throw new DomainError('invalid_pagination', 'Invalid offset, limit or ordering.', 400);
|
|
339
|
+
const filter = filters(where), direction = order === 'asc' ? 'ASC' : 'DESC';
|
|
340
|
+
return this.db.prepare(`SELECT body FROM records WHERE kind=?${filter.sql}
|
|
341
|
+
ORDER BY rowid ${direction} LIMIT ? OFFSET ?`)
|
|
342
|
+
.all(kind, ...filter.parameters, limit, offset).map(row => JSON.parse(row.body));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
listAll(kind, { where = {}, maxRecords = 100000 } = {}) {
|
|
346
|
+
identity(kind, 'Record kind');
|
|
347
|
+
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || maxRecords > 100000)
|
|
348
|
+
throw new DomainError('invalid_limit', 'Full scans allow 1 to 100000 records.', 400);
|
|
349
|
+
return this.transaction(() => {
|
|
350
|
+
const filter = filters(where);
|
|
351
|
+
const count = this.db.prepare(`SELECT COUNT(*) AS total FROM records WHERE kind=?${filter.sql}`)
|
|
352
|
+
.get(kind, ...filter.parameters).total;
|
|
353
|
+
if (count > maxRecords)
|
|
354
|
+
throw new DomainError('record_scan_capacity', 'No partial result was returned.', 409, { kind, count, maxRecords });
|
|
355
|
+
return this.list(kind, { where, limit: count });
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
save(kind, record, { expectedRevision = null } = {}) {
|
|
360
|
+
this.assertOpen(); identity(kind, 'Record kind');
|
|
361
|
+
const copy = jsonClone(record);
|
|
362
|
+
if (!copy || typeof copy !== 'object' || Array.isArray(copy))
|
|
363
|
+
throw new DomainError('invalid_record', 'A record must be a plain object.', 400);
|
|
364
|
+
identity(copy.id, 'Record id');
|
|
365
|
+
if (expectedRevision !== null && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1))
|
|
366
|
+
throw new DomainError('invalid_revision', 'An explicit positive revision is required.', 400);
|
|
367
|
+
return this.transaction(() => {
|
|
368
|
+
const existing = this.get(kind, copy.id);
|
|
369
|
+
if (existing && IMMUTABLE.has(kind))
|
|
370
|
+
throw new DomainError('immutable_record', 'Original sources and delivery references cannot be rewritten.');
|
|
371
|
+
if ((expectedRevision === null && existing) ||
|
|
372
|
+
(expectedRevision !== null && existing?.revision !== expectedRevision))
|
|
373
|
+
throw new DomainError('revision_conflict', 'Record changed or creation identity already exists.');
|
|
374
|
+
const now = new Date().toISOString(), sequence = this.currentSequence() + 1;
|
|
375
|
+
if (!Number.isSafeInteger(sequence)) throw new DomainError('sequence_exhausted', 'Mutation sequence exhausted.', 500);
|
|
376
|
+
this.db.prepare(`UPDATE ${this.metaTable} SET value=? WHERE key='mutation_sequence'`).run(String(sequence));
|
|
377
|
+
const saved = { ...copy, revision: (existing?.revision ?? 0) + 1,
|
|
378
|
+
createdAt: existing?.createdAt ?? now, updatedAt: now,
|
|
379
|
+
createdSequence: existing?.createdSequence ?? sequence, updatedSequence: sequence,
|
|
380
|
+
...(this.legacyRecords ? { sequenceEstablishedBy: existing?.sequenceEstablishedBy || 'native' } : {}) };
|
|
381
|
+
if (existing) {
|
|
382
|
+
const result = this.db.prepare('UPDATE records SET revision=?,updated_at=?,body=? WHERE kind=? AND id=? AND revision=?')
|
|
383
|
+
.run(saved.revision, now, JSON.stringify(saved), kind, saved.id, expectedRevision);
|
|
384
|
+
if (result.changes !== 1) throw new DomainError('revision_conflict', 'Record changed before save.');
|
|
385
|
+
} else this.db.prepare('INSERT INTO records VALUES (?,?,?,?,?,?)')
|
|
386
|
+
.run(kind, saved.id, saved.revision, saved.createdAt, saved.updatedAt, JSON.stringify(saved));
|
|
387
|
+
return jsonClone(saved);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
close() {
|
|
392
|
+
if (!this.closed) { this.db.close(); this.closed = true; }
|
|
393
|
+
}
|
|
394
|
+
}
|
package/src/tree/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { CONTRACT, canonicalJson, digest, fail } from './contracts.mjs';
|
|
2
|
+
import { TreeService } from './tree.mjs';
|
|
3
|
+
|
|
4
|
+
export class AttributionService {
|
|
5
|
+
constructor({ repository, sources, contextBuilder, jobs, modelOwner, modelConfig, cancelJob, timers = { setTimeout, clearTimeout } }) {
|
|
6
|
+
Object.assign(this, { repo: repository, sources, builder: contextBuilder, jobs, owner: modelOwner, modelConfig, cancelJob, timers }); this.pending = new Map();
|
|
7
|
+
}
|
|
8
|
+
existing(sessionId, nativeTurn) { return this.repo.get('tree_turns', digest(['turn', sessionId, nativeTurn])); }
|
|
9
|
+
selection(sessionId) { return this.repo.meta().attributionSelections?.[sessionId] ?? { nodeId: null, revision: 0 }; }
|
|
10
|
+
candidateNodes(at = this.repo.now(), branchId = null) {
|
|
11
|
+
const service = new TreeService({ repository: this.repo, context: () => ({ actor: { kind: 'program', id: 'tree-attribution' } }) });
|
|
12
|
+
const now = Date.parse(at);
|
|
13
|
+
const rows = this.repo.list('tree_nodes', n => !n.deleted).filter(node => {
|
|
14
|
+
if (branchId && !this.repo.ancestors(node.id).some(n => n.id === branchId)) return false;
|
|
15
|
+
if (node.id === branchId || node.pinned === true || ['active', 'paused'].includes(node.status)) return true;
|
|
16
|
+
const owner = node.statusOrigin === 'cascade' && node.cascadeFromId ? this.repo.get('tree_nodes', node.cascadeFromId) : node;
|
|
17
|
+
return now - Date.parse(owner.statusChangedAt ?? owner.updatedAt) <= service.parameter('retentionDays', owner.id) * 86400000;
|
|
18
|
+
}).sort((a, b) => Number(b.pinned === true) - Number(a.pinned === true) || a.id.localeCompare(b.id));
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
capacity(sessionId) {
|
|
22
|
+
const selection = this.selection(sessionId), selected = selection.nodeId && this.repo.get('tree_nodes', selection.nodeId);
|
|
23
|
+
const selectionMissing = Boolean(selection.nodeId && (!selected || selected.deleted));
|
|
24
|
+
const totalCount = this.candidateNodes().length, count = selection.nodeId ? selectionMissing ? 0 : this.candidateNodes(this.repo.now(), selection.nodeId).length : totalCount;
|
|
25
|
+
return { limit: 2000, totalCount, count, selection, selectionMissing, blocked: selectionMissing || count > 2000,
|
|
26
|
+
path: selected && !selected.deleted ? this.repo.ancestors(selected.id).map(n => ({ id: n.id, name: n.name })) : [],
|
|
27
|
+
pending: Object.values(this.repo.meta().attributionPending ?? {}).filter(p => p.sessionId === sessionId && p.state !== 'consumed').map(p => ({ id: p.id, state: p.state, revision: p.revision, text: p.preview, nativeTurn: p.nativeTurn })) };
|
|
28
|
+
}
|
|
29
|
+
candidates(at = this.repo.now(), sessionId = null) {
|
|
30
|
+
const selection = this.selection(sessionId);
|
|
31
|
+
if (selection.nodeId && (!this.repo.get('tree_nodes', selection.nodeId) || this.repo.get('tree_nodes', selection.nodeId).deleted)) fail('CANDIDATE_CAPACITY', '选定的目标已不存在,请重新选择一支目标后继续。');
|
|
32
|
+
const rows = this.candidateNodes(at, selection.nodeId);
|
|
33
|
+
if (rows.length > 2000) fail('CANDIDATE_CAPACITY', `当前范围有${rows.length}个候选,超过2000个;请选择一支更小的目标后继续,尚未调用模型。`);
|
|
34
|
+
return rows.map(node => ({ id: node.id, name: node.name, status: node.status, statusOrigin: node.statusOrigin, path: this.repo.ancestors(node.id).map(n => ({ id: n.id, name: n.name })), oneLine: this.builder.summary(node)?.oneLine ?? '', revision: node.revision }));
|
|
35
|
+
}
|
|
36
|
+
freeze(sessionId, nativeTurn, messages) {
|
|
37
|
+
const userSources = this.sources.claimed(sessionId, messages); if (!userSources.length) fail('PROGRAM_NOT_USER', 'Only a new actual human batch needs attribution');
|
|
38
|
+
const prior = this.repo.list('tree_turns', t => t.sessionId === sessionId && t.nativeTurn < nativeTurn).sort((a, b) => b.nativeTurn - a.nativeTurn)[0];
|
|
39
|
+
const selection = this.selection(sessionId), candidates = this.candidates(this.repo.now(), sessionId), candidateIds = new Set(candidates.map(n => n.id));
|
|
40
|
+
const previousNodeIds = (prior?.nodeIds ?? [selection.nodeId ?? this.repo.rootId()]).filter(id => candidateIds.has(id)), scopeNodes = previousNodeIds.length ? previousNodeIds : [selection.nodeId ?? this.repo.rootId()];
|
|
41
|
+
const context = this.builder.build(scopeNodes, 'attribution');
|
|
42
|
+
// The explicit branch is the complete discovery range for this judgement.
|
|
43
|
+
// Ancestor knowledge is inherited, but the global discovery list is omitted.
|
|
44
|
+
if (selection.nodeId) context.segments = context.segments.filter(s => s.key !== 'global-overview');
|
|
45
|
+
const priorAnswer = prior ? this.sources.turn(sessionId, prior.nativeTurn).sources.filter(s => s.origin === 'model' && this.sources.resolve(s.ref)).at(-1) : null;
|
|
46
|
+
const sources = [...userSources, ...(priorAnswer ? [priorAnswer] : [])], knownItems = this.builder.activeItems(context.ancestorPaths.flat(), 'attribution').map(({ item }) => ({ id: item.id, version: item.activeVersion, nodeId: item.nodeId, kind: item.kind, userProtected: item.userProtected }));
|
|
47
|
+
// Candidate node revisions also change when an unrelated turn is linked.
|
|
48
|
+
// Recheck the actual candidate fields atomically at commit instead of
|
|
49
|
+
// rejecting a correct judgment on every unrelated bookkeeping update.
|
|
50
|
+
const revisions = {};
|
|
51
|
+
for (const i of knownItems) revisions[`tree_items:${i.id}`] = this.repo.get('tree_items', i.id).revision;
|
|
52
|
+
return { input: { inputVersion: CONTRACT, role: 'attribution', scope: { nodeIds: scopeNodes, ancestorPaths: context.ancestorPaths, treeRevision: context.treeRevision }, sources, knownItems,
|
|
53
|
+
allowedIds: { nodeIds: candidates.map(n => n.id), fileIds: [], subjectIds: [] }, changeReceipts: [], candidates,
|
|
54
|
+
context: context.segments, currentTurn: { sessionId, nativeTurn, userSourceIds: userSources.map(s => s.id), selectionRevision: selection.revision, selectedBranchId: selection.nodeId } }, revisions, scopeNodes };
|
|
55
|
+
}
|
|
56
|
+
applyResult(result, { job, requestId }) {
|
|
57
|
+
const input = job.input, { sessionId, nativeTurn, userSourceIds } = input.currentTurn;
|
|
58
|
+
const old = this.existing(sessionId, nativeTurn);
|
|
59
|
+
if (old) fail('JOB_STALE', 'This turn was already assigned, corrected or routed by a deadline fallback');
|
|
60
|
+
if (this.selection(sessionId).revision !== (input.currentTurn.selectionRevision ?? 0)) fail('JOB_STALE', '用户已改变本聊天的目标范围,本次旧判断不再采用');
|
|
61
|
+
if (input.currentTurn.selectedBranchId && input.currentTurn.selectedBranchId !== this.repo.rootId() && result.actions.some(a => a.action === 'create')) fail('ATTRIBUTION_RANGE', '选定分支时只能在候选父目标下创建子目标,不能在范围外新建');
|
|
62
|
+
return this.repo.transaction(() => {
|
|
63
|
+
if (this.existing(sessionId, nativeTurn) || this.selection(sessionId).revision !== (input.currentTurn.selectionRevision ?? 0)) fail('JOB_STALE', 'Turn or selected branch changed while the model ran');
|
|
64
|
+
const current = this.candidates(this.repo.now(), sessionId);
|
|
65
|
+
const meaning = candidates => candidates.map(({ revision: _revision, ...candidate }) => candidate);
|
|
66
|
+
if (canonicalJson(meaning(current)) !== canonicalJson(meaning(input.candidates))) fail('JOB_STALE', 'Attribution candidate information changed while the model ran');
|
|
67
|
+
const expected = { ...job.inputRevisionSet, ...Object.fromEntries(current.map(n => [`tree_nodes:${n.id}`, n.revision])) };
|
|
68
|
+
const resolve = id => { const source = input.sources.find(s => s.id === id); if (!source || !this.sources.resolve(source.ref)) fail('JOB_STALE', 'Attribution source changed'); return source.ref; };
|
|
69
|
+
const refs = userSourceIds.map(resolve), tree = new TreeService({ repository: this.repo, resolveSource: ref => this.sources.resolve(ref), context: () => ({ actor: { kind: 'model', id: job.id, role: 'attribution' }, nodeIds: input.scope.nodeIds, candidateNodeIds: input.allowedIds.nodeIds }) });
|
|
70
|
+
return tree.applyAttribution({ requestId, expected, input: { sessionId, nativeTurn, userSourceRefs: refs, sourceRefs: refs,
|
|
71
|
+
actions: result.actions, statusChanges: result.statusChanges.map(s => ({ nodeId: s.nodeId, status: s.status, sourceRefs: [resolve(s.sourceId)] })), attributionRunId: job.id } });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
fallback(sessionId, nativeTurn, messages, reason, runId = null) {
|
|
75
|
+
const old = this.existing(sessionId, nativeTurn); if (old) return old;
|
|
76
|
+
const refs = this.sources.claimed(sessionId, messages).map(s => s.ref); if (!refs.length) fail('PROGRAM_NOT_USER', 'No human original to route');
|
|
77
|
+
const tree = new TreeService({ repository: this.repo, resolveSource: ref => this.sources.resolve(ref), context: () => ({ actor: { kind: 'program', id: 'tree-attribution' } }) });
|
|
78
|
+
const branchId = this.selection(sessionId).nodeId;
|
|
79
|
+
tree.applyAttribution({ requestId: digest(['attribution-fallback', sessionId, nativeTurn]), expected: {}, input: { sessionId, nativeTurn, userSourceRefs: refs, sourceRefs: refs, actions: branchId ? [{ action: 'continue', nodeId: branchId }] : [], statusChanges: [], failure: reason.slice(0, 2000), attributionRunId: runId } });
|
|
80
|
+
return this.existing(sessionId, nativeTurn);
|
|
81
|
+
}
|
|
82
|
+
attribute(request) {
|
|
83
|
+
const key = digest([request.sessionId, request.nativeTurn]); if (this.pending.has(key)) return this.pending.get(key);
|
|
84
|
+
const promise = this.#attribute(request); this.pending.set(key, promise); void promise.finally(() => this.pending.delete(key)).catch(() => {}); return promise;
|
|
85
|
+
}
|
|
86
|
+
cancelPending(jobId, reason) {
|
|
87
|
+
const current = this.jobs.get(jobId);
|
|
88
|
+
if (['queued', 'prepared', 'submitted', 'validated'].includes(current.status)) this.jobs.transition(jobId, 'cancelled', { cancellationReason: reason });
|
|
89
|
+
if (['committed', 'cancelled', 'failed', 'stale', 'unknown'].includes(current.status)) return;
|
|
90
|
+
const result = this.cancelJob(jobId);
|
|
91
|
+
if (result?.then) { void Promise.resolve(result).catch(() => {}); fail('CANCELLATION_REQUIRED', 'Cancellation callback must be synchronous'); }
|
|
92
|
+
}
|
|
93
|
+
async #attribute({ sessionId, nativeTurn, messages, signal, agent, modelSelection }) {
|
|
94
|
+
signal?.throwIfAborted(); const old = this.existing(sessionId, nativeTurn); if (old) return old;
|
|
95
|
+
if (!messages.some(m => m.source?.kind === 'user')) return null;
|
|
96
|
+
if (typeof this.cancelJob !== 'function') fail('CANCELLATION_REQUIRED', 'Attribution needs an owned synchronous cancellation callback');
|
|
97
|
+
let job, timer, abort;
|
|
98
|
+
try {
|
|
99
|
+
const frozen = this.freeze(sessionId, nativeTurn, messages);
|
|
100
|
+
job = this.jobs.enqueue({ role: 'attribution', key: `${sessionId}:${nativeTurn}`, nodeIds: frozen.scopeNodes, input: frozen.input, revisionSet: frozen.revisions,
|
|
101
|
+
config: this.modelConfig(agent, modelSelection), trigger: { kind: 'human_input', producerRole: 'user', origin: 'user', nativeSessionId: sessionId, nativeTurn, ordinal: this.repo.meta().lastEventOrdinal } });
|
|
102
|
+
const cancelled = new Promise((_, reject) => {
|
|
103
|
+
const stop = code => {
|
|
104
|
+
try { this.cancelPending(job.id, code); } catch (error) { reject(error); return; }
|
|
105
|
+
reject(Object.assign(new Error(code), { code }));
|
|
106
|
+
};
|
|
107
|
+
timer = this.timers.setTimeout(() => stop('ATTRIBUTION_DEADLINE'), 12000); abort = () => stop('ATTRIBUTION_ABORTED'); signal?.addEventListener('abort', abort, { once: true });
|
|
108
|
+
if (signal?.aborted) abort();
|
|
109
|
+
});
|
|
110
|
+
await Promise.race([this.owner.run(job.id), cancelled]); signal?.throwIfAborted();
|
|
111
|
+
const turn = this.existing(sessionId, nativeTurn); if (turn) return turn;
|
|
112
|
+
const result = this.jobs.get(job.id); this.cancelPending(job.id, 'ATTRIBUTION_UNAVAILABLE');
|
|
113
|
+
return this.fallback(sessionId, nativeTurn, messages, `Attribution ${result.status}: ${canonicalJson(result.error ?? {})}`, job.id);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (job) { try { this.cancelPending(job.id, error.code ?? 'ATTRIBUTION_UNAVAILABLE'); } catch (cancelError) { error = cancelError; } }
|
|
116
|
+
if (signal?.aborted || ['ATTRIBUTION_ABORTED', 'CANDIDATE_CAPACITY'].includes(error.code)) throw error;
|
|
117
|
+
return this.fallback(sessionId, nativeTurn, messages, `${error.code ?? 'ATTRIBUTION_UNAVAILABLE'}: ${error.message}`, job?.id ?? null);
|
|
118
|
+
} finally { if (timer !== undefined) this.timers.clearTimeout(timer); if (abort) signal?.removeEventListener('abort', abort); }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { canonicalJson, digest, fail, oneOf } from './contracts.mjs';
|
|
2
|
+
|
|
3
|
+
const actor = { kind: 'program', id: 'tree-clusters' };
|
|
4
|
+
export class ClusterService {
|
|
5
|
+
constructor({ repository, signals }) { this.repo = repository; this.signals = signals; }
|
|
6
|
+
group(category, polarity) {
|
|
7
|
+
return this.signals.episodes().filter(e => e.state !== 'control' && e.signals.some(s => s.category === category && s.polarity === polarity)).sort((a, b) => a.ordinal - b.ordinal || a.id.localeCompare(b.id));
|
|
8
|
+
}
|
|
9
|
+
signature(episodes) { return digest(episodes.map(e => [e.id, e.nodeIds, e.beforeRefs, e.reactionRefs, e.resolutionRefs, e.signals.map(s => s.id).sort()])); }
|
|
10
|
+
scope(episodes) {
|
|
11
|
+
const paths = [...new Set(episodes.flatMap(e => e.nodeIds))].map(id => this.repo.ancestors(id).map(n => n.id));
|
|
12
|
+
return paths[0]?.filter(id => paths.every(p => p.includes(id))).at(-1) ?? this.repo.rootId();
|
|
13
|
+
}
|
|
14
|
+
candidate(category, polarity) {
|
|
15
|
+
oneOf(polarity, ['negative', 'strong'], 'cluster polarity');
|
|
16
|
+
const history = this.repo.list('tree_clusters', c => c.category === category && c.polarity === polarity).sort((a, b) => a.epoch - b.epoch);
|
|
17
|
+
const previous = history.at(-1), all = this.group(category, polarity);
|
|
18
|
+
if (previous?.status === 'candidate') {
|
|
19
|
+
const selected = all.filter(e => previous.episodeIds.includes(e.id));
|
|
20
|
+
if (this.signature(selected) === previous.evidenceHash) return previous;
|
|
21
|
+
// Corrected evidence cannot retain an old pending diagnosis.
|
|
22
|
+
this.repo.command('invalidateClusterCandidate', { requestId: digest(['cluster-stale', previous.id, previous.revision]), expected: { ['tree_clusters:' + previous.id]: previous.revision }, input: { clusterId: previous.id } }, actor, () => {
|
|
23
|
+
this.repo.save('tree_clusters', { ...previous, status: 'stale', needsRecheck: true }, previous.revision); return { clusterId: previous.id };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const reviewed = history.filter(c => ['common', 'reviewed_none'].includes(c.status) && !c.needsRecheck).at(-1);
|
|
27
|
+
const watermark = reviewed?.reviewWatermark ?? 0, episodes = all.filter(e => e.ordinal > watermark);
|
|
28
|
+
if (episodes.length < 3) return null;
|
|
29
|
+
const epoch = (previous?.epoch ?? 0) + 1, evidenceHash = this.signature(episodes), id = digest(['cluster', category, polarity, epoch, evidenceHash]);
|
|
30
|
+
const controls = polarity === 'strong' ? this.signals.episodes().filter(e => e.state === 'control' && e.category === category && e.nodeIds.some(id => episodes.some(s => s.nodeIds.includes(id)))) : [];
|
|
31
|
+
return this.repo.command('createClusterCandidate', { requestId: id, expected: {}, input: { category, polarity, epoch, evidenceHash } }, actor, () => {
|
|
32
|
+
this.repo.save('tree_clusters', { id, category, polarity, epoch, episodeIds: episodes.map(e => e.id), status: 'candidate', methodIds: [], reviewWatermark: watermark,
|
|
33
|
+
triggerWatermark: Math.max(...episodes.map(e => e.ordinal)), recurrenceCount: reviewed?.recurrenceCount ?? 0, previousClusterId: reviewed?.id ?? null,
|
|
34
|
+
previousMethodIds: reviewed?.methodIds ?? [], evidenceHash, nodeId: this.scope(episodes), controlIds: controls.map(e => e.id), needsRecheck: false }); return { clusterId: id };
|
|
35
|
+
}).result.clusterId && this.repo.get('tree_clusters', id);
|
|
36
|
+
}
|
|
37
|
+
assertCurrent(cluster) {
|
|
38
|
+
const current = this.repo.get('tree_clusters', cluster.id), episodes = this.group(cluster.category, cluster.polarity).filter(e => cluster.episodeIds.includes(e.id));
|
|
39
|
+
if (current?.revision !== cluster.revision || current.status !== 'candidate' || this.signature(episodes) !== cluster.evidenceHash) fail('JOB_STALE', 'Cluster evidence or category correction changed');
|
|
40
|
+
return episodes;
|
|
41
|
+
}
|
|
42
|
+
freeze(cluster) {
|
|
43
|
+
const episodes = this.assertCurrent(cluster), controls = this.signals.episodes().filter(e => cluster.controlIds.includes(e.id));
|
|
44
|
+
return { ...cluster, episodes: episodes.map(e => this.signals.material(e)), controls: controls.map(e => this.signals.material(e)) };
|
|
45
|
+
}
|
|
46
|
+
review({ clusterId, clusterVersion, verdict, commonality, evidence }, { job, requestId, methodIds = [], recurrence = null }) {
|
|
47
|
+
const cluster = this.repo.get('tree_clusters', clusterId);
|
|
48
|
+
if (!cluster || cluster.revision !== clusterVersion) fail('JOB_STALE', 'Cluster version changed');
|
|
49
|
+
this.assertCurrent(cluster); oneOf(verdict, ['common', 'none'], 'cluster verdict');
|
|
50
|
+
if (verdict === 'none' && (methodIds.length || recurrence) || verdict === 'common' && !commonality.trim()) fail('CLUSTER_DIAGNOSIS', 'No common cause cannot create a method or recurrence claim');
|
|
51
|
+
const evidenceSources = evidence.map(id => job.input.sources.find(s => s.id === id));
|
|
52
|
+
if (evidenceSources.some(s => !s)) fail('OUTPUT_REFERENCE', 'Unknown cluster evidence');
|
|
53
|
+
const episodeRefs = this.assertCurrent(cluster).map(e => e.reactionRefs);
|
|
54
|
+
if (episodeRefs.filter(refs => refs.some(ref => evidenceSources.some(s => digest(s.ref) === digest(ref)))).length < 3) fail('CLUSTER_EVIDENCE', 'Diagnosis needs three distinct original episodes');
|
|
55
|
+
if (recurrence && (!cluster.previousMethodIds.includes(recurrence.methodId) || !job.input.knownItems.some(i => i.id === recurrence.methodId && i.version === recurrence.methodVersion) || !recurrence.expectation)) fail('CLUSTER_RECURRENCE', 'Recurrence requires an explicit current method and expectation binding');
|
|
56
|
+
return this.repo.command('reviewCluster', { requestId, expected: { ['tree_clusters:' + cluster.id]: clusterVersion }, input: { clusterId, verdict, commonality, evidence, methodIds, recurrence } }, { kind: 'model', id: job.id, role: 'verification' }, () => {
|
|
57
|
+
this.repo.save('tree_clusters', { ...cluster, status: verdict === 'none' ? 'reviewed_none' : 'common', commonality, evidenceRefs: evidenceSources.map(s => s.ref), methodIds,
|
|
58
|
+
reviewWatermark: cluster.triggerWatermark, recurrence, recurrenceCount: cluster.recurrenceCount + (recurrence ? 1 : 0), needsRecheck: false }, cluster.revision); return { clusterId, verdict };
|
|
59
|
+
}).result;
|
|
60
|
+
}
|
|
61
|
+
}
|