@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/tree/ui.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { canonicalJson, digest, fail, shape, text, integer, array, oneOf, validateShort, validateLong, STATUSES, CATEGORIES } from './contracts.mjs';
|
|
3
|
+
import { TreeService, SignalService } from './tree.mjs';
|
|
4
|
+
import { ItemService } from './items.mjs';
|
|
5
|
+
import { evidenceResolver, effectiveSignals, signalPolarity, EpisodeSignals } from './signals.mjs';
|
|
6
|
+
import { contentHash } from './host-process.mjs';
|
|
7
|
+
|
|
8
|
+
export const TREE_ENDPOINTS = ['session.current', 'session.selectBranch', 'tree.list', 'node.details', 'turn.details', 'source.read', 'node.rename', 'node.move', 'node.status', 'turn.correct', 'turn.undoCreate', 'turn.undoStatus', 'item.confirm', 'item.edit', 'item.withdraw', 'item.undoCorrection', 'hook.disable', 'signal.correct', 'snapshots.list', 'snapshots.preview', 'snapshots.restore', 'metrics.read', 'reports.list', 'reports.details', 'reports.export', 'parameters.set', 'receipts.read'];
|
|
9
|
+
const readEndpoints = new Set(['session.current', 'tree.list', 'node.details', 'turn.details', 'source.read', 'snapshots.list', 'metrics.read', 'reports.list', 'reports.details', 'receipts.read']);
|
|
10
|
+
const versionId = (id, version) => digest(['item_version', id, version]);
|
|
11
|
+
const methodId = (id, version) => digest(['method', id, version]);
|
|
12
|
+
|
|
13
|
+
// Called by the trusted turn adapter immediately after capture, never by RPC.
|
|
14
|
+
export function bindTurnSnapshot(repository, snapshotId, logicalTurnId) {
|
|
15
|
+
const snapshot = repository.get('tree_snapshots', snapshotId), turn = repository.get('tree_turns', logicalTurnId);
|
|
16
|
+
if (!snapshot || !turn || snapshot.logicalTurnId && snapshot.logicalTurnId !== turn.id) fail('SNAPSHOT_BINDING', 'Snapshot requires its exact originating turn');
|
|
17
|
+
return repository.command('bindTurnSnapshot', { requestId: digest(['snapshot-turn', snapshotId, logicalTurnId]), expected: {}, input: { snapshotId, logicalTurnId } }, { kind: 'program', id: 'tree-turn-adapter' }, () => {
|
|
18
|
+
repository.save('tree_snapshots', { ...snapshot, logicalTurnId }, snapshot.revision); return { snapshotId, logicalTurnId };
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Host-owned domain adapter. Browser data never supplies the actor or scope. */
|
|
23
|
+
export class TreeUIService {
|
|
24
|
+
constructor({ repository, resolveSource, metrics, reports, snapshotsFor, browserScope, attribution, selectBranch }) {
|
|
25
|
+
if (typeof browserScope !== 'function') fail('BROWSER_SCOPE_REQUIRED', 'A host-owned browser scope is required');
|
|
26
|
+
Object.assign(this, { repo: repository, metrics, reports, snapshotsFor, browserScope, attribution, selectBranch }); this.resolveSource = evidenceResolver(repository, resolveSource);
|
|
27
|
+
}
|
|
28
|
+
authorize(context) {
|
|
29
|
+
if (context.actor?.kind !== 'user' || context.actor.id !== this.repo.userId || context.signal?.aborted !== false) fail('FORBIDDEN', 'Current authenticated owner required', 403);
|
|
30
|
+
const scope = this.browserScope(context); if (!scope || scope.then || !Array.isArray(scope.nodeIds) || new Set(scope.nodeIds).size !== scope.nodeIds.length || scope.nodeIds.some(id => { const n = this.repo.get('tree_nodes', id); return !n || n.deleted; })) fail('BROWSE_SCOPE', 'Browser scope is unavailable', 403);
|
|
31
|
+
return scope;
|
|
32
|
+
}
|
|
33
|
+
scoped(id, scope) { if (!scope.nodeIds.includes(id)) fail('BROWSE_SCOPE', 'This node is outside the current browser scope', 403); const node = this.repo.get('tree_nodes', id); if (!node || node.deleted) fail('NODE_NOT_FOUND', 'Node not found'); return node; }
|
|
34
|
+
turnJobs(turn) {
|
|
35
|
+
const rows = this.repo.list('tree_jobs', j => j.input?.turnId === turn.id || j.trigger?.turnId === turn.id);
|
|
36
|
+
const assignment = j => j.input?.currentTurn?.assignmentRevision ?? j.input?.assignmentRevision;
|
|
37
|
+
const learning = rows.filter(j => j.role === 'learning').sort((a, b) => b.enqueuedOrder - a.enqueuedOrder || b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
|
|
38
|
+
const owner = j => learning.find(other => other.nodeIds[0] === j.nodeIds[0] && assignment(other) === assignment(j));
|
|
39
|
+
// Scheduling intents are not a second worker. Old intents remain queued
|
|
40
|
+
// after rejected/unknown output; project their actual owner's state without
|
|
41
|
+
// rewriting evidence or dispatching another request from a read endpoint.
|
|
42
|
+
const correctionOwner = intent => rows.find(j => j.role === 'verification' && j.trigger?.intentId === intent.id);
|
|
43
|
+
return rows.filter(j => j.role || (j.kind !== 'learning' || !owner(j)) && (j.kind !== 'verification' || !correctionOwner(j))).map(j => ({ id: j.id, role: j.role ?? j.kind, status: j.status,
|
|
44
|
+
current: (assignment(j) === undefined || assignment(j) === turn.assignmentRevision) && (j.role !== 'learning' || owner(j)?.id === j.id), error: j.error?.code ?? j.error ?? null,
|
|
45
|
+
rejectedItems: j.rejectedItems ?? [] }));
|
|
46
|
+
}
|
|
47
|
+
page(rows, payload, key) {
|
|
48
|
+
const limit = payload.limit ?? 50; integer(limit, 'limit', 1, 100);
|
|
49
|
+
const sorted = rows.toSorted((a, b) => a.id.localeCompare(b.id)), version = digest(sorted.map(r => [r.id, r.revision]));
|
|
50
|
+
let after = '';
|
|
51
|
+
if (payload.cursor) { text(payload.cursor, 'cursor', 2048); let cursor; try { cursor = JSON.parse(Buffer.from(payload.cursor, 'base64url').toString()); } catch { fail('CURSOR_INVALID', 'Invalid page cursor'); } if (cursor.key !== key || cursor.version !== version || typeof cursor.after !== 'string') fail('REVISION_CONFLICT', 'List changed; refresh from the first page'); after = cursor.after; }
|
|
52
|
+
const rest = sorted.filter(r => r.id > after), selected = rest.slice(0, limit);
|
|
53
|
+
return { rows: selected, nextCursor: rest.length > limit ? Buffer.from(canonicalJson({ key, version, after: selected.at(-1).id })).toString('base64url') : null };
|
|
54
|
+
}
|
|
55
|
+
read(endpoint, payload, context, scope) {
|
|
56
|
+
if (endpoint === 'session.current') { shape(payload, []); const turn = this.repo.list('tree_turns', t => t.sessionId === context.sessionId).sort((a, b) => b.nativeTurn - a.nativeTurn)[0];
|
|
57
|
+
if (turn && turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Current turn outside scope', 403);
|
|
58
|
+
const nodeIds = turn?.nodeIds ?? [this.repo.rootId()]; return { attribution: this.attribution && scope.nodeIds.length === this.repo.list('tree_nodes', n => !n.deleted).length ? this.attribution.capacity(context.sessionId) : null, turnId: turn?.id ?? null, paths: nodeIds.map(id => this.repo.ancestors(id).filter(n => scope.nodeIds.includes(n.id))), turnIds: this.repo.list('tree_turns', t => t.sessionId === context.sessionId && t.nodeIds.every(id => scope.nodeIds.includes(id))).map(t => ({ nativeTurn: t.nativeTurn, turnId: t.id })) }; }
|
|
59
|
+
if (endpoint === 'tree.list') {
|
|
60
|
+
shape(payload, [], ['statusFilter', 'cursor', 'limit']); const statuses = payload.statusFilter ?? STATUSES; array(statuses, 'statusFilter', 4).forEach(s => oneOf(s, STATUSES, 'status'));
|
|
61
|
+
const page = this.page(this.repo.list('tree_nodes', n => !n.deleted && scope.nodeIds.includes(n.id) && statuses.includes(n.status)), payload, 'tree:' + digest(scope.nodeIds));
|
|
62
|
+
return { nodes: page.rows.map(n => ({ ...n, oneLine: n.currentSummaryId ? this.repo.get('tree_summaries', n.currentSummaryId)?.oneLine ?? '' : '' })), nextCursor: page.nextCursor, treeRevision: this.repo.meta().treeRevision };
|
|
63
|
+
}
|
|
64
|
+
if (endpoint === 'node.details') {
|
|
65
|
+
shape(payload, ['nodeId']); const node = this.scoped(payload.nodeId, scope), items = this.repo.list('tree_items', i => i.nodeId === node.id).map(item => ({ ...item, current: this.repo.get('tree_item_versions', versionId(item.id, item.activeVersion ?? item.draftVersion)),
|
|
66
|
+
history: this.repo.list('tree_item_versions', v => v.itemId === item.id), method: item.kind === 'method' ? this.repo.get('tree_methods', methodId(item.id, item.activeVersion ?? item.draftVersion)) : null,
|
|
67
|
+
hooks: this.repo.list('tree_hooks', h => h.methodId === item.id && h.version === item.activeVersion), scripts: item.kind !== 'method' ? [] : (this.repo.get('tree_methods', methodId(item.id, item.activeVersion ?? item.draftVersion))?.scripts ?? []).map(ref => { const artifact = this.repo.list('tree_script_artifacts', a => a.scriptId === ref.id && a.version === ref.version)[0]; return { id: ref.id, version: ref.version, usage: artifact?.usage ?? null, readOnlyProbeCount: artifact?.probeIds?.length ?? 0, notVerified: artifact?.notVerified ?? [] }; }), supplement: item.kind === 'supplement' ? this.repo.get('tree_supplements', digest(['supplement', item.id, item.activeVersion])) : null }));
|
|
68
|
+
return { node, path: this.repo.ancestors(node.id).filter(n => scope.nodeIds.includes(n.id)), summary: node.currentSummaryId ? this.repo.get('tree_summaries', node.currentSummaryId) : null, items,
|
|
69
|
+
children: this.repo.list('tree_nodes', n => !n.deleted && n.parentId === node.id && scope.nodeIds.includes(n.id)), promotionHistory: items.filter(i => i.originChain?.length > 1).map(i => ({ itemId: i.id, originChain: i.originChain, reason: i.promotionReason })),
|
|
70
|
+
parameters: ['retentionDays', 'leaveTimeoutHours'].map(name => { const service = new TreeService({ repository: this.repo }); return { name, value: service.parameter(name, node.id), local: this.repo.get('tree_parameters', digest(['parameter', name, node.id])) }; }),
|
|
71
|
+
revisionSet: { ['tree_nodes:' + node.id]: node.revision, ...Object.fromEntries(items.map(i => ['tree_items:' + i.id, i.revision])) } };
|
|
72
|
+
}
|
|
73
|
+
if (endpoint === 'turn.details') {
|
|
74
|
+
shape(payload, ['turnId']); const turn = this.repo.get('tree_turns', payload.turnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Turn is outside browser scope', 403);
|
|
75
|
+
return { turn, nodes: turn.nodeIds.map(id => this.scoped(id, scope)), signals: effectiveSignals(this.repo, this.resolveSource).filter(s => s.sourceRefs.some(r => turn.userSourceRefs.some(ref => digest(ref) === digest(r)))),
|
|
76
|
+
statusChanges: Object.values(this.repo.meta().treeObservations ?? {}).flat().filter(o => !o.reversalOf && o.logicalTurnId === turn.id && o.type === 'status' && o.origin !== 'cascade' && scope.nodeIds.includes(o.nodeId)).map(o => { const group = Object.values(this.repo.meta().treeObservations).flat().filter(other => other.commandReceiptId === o.commandReceiptId && other.type === 'status'); return { ...o, nodeName: this.repo.get('tree_nodes', o.nodeId)?.name, expectedRevisions: Object.fromEntries(group.filter(other => scope.nodeIds.includes(other.nodeId)).map(other => [other.nodeId, this.repo.get('tree_nodes', other.nodeId)?.revision])), reversed: Boolean(this.repo.meta().reversedTreeChanges?.[o.commandReceiptId]) }; }),
|
|
77
|
+
hookRuns: this.repo.list('tree_hook_runs', r => r.logicalTurnId === turn.logicalTurnId), jobs: this.turnJobs(turn),
|
|
78
|
+
snapshots: this.repo.list('tree_snapshots', s => s.logicalTurnId === turn.logicalTurnId).map(s => ({ id: s.id, coverage: s.coverage, restoreState: s.restoreState })) };
|
|
79
|
+
}
|
|
80
|
+
if (endpoint === 'metrics.read') { shape(payload, [], ['scopeNodeId', 'range']); if (payload.scopeNodeId) this.scoped(payload.scopeNodeId, scope); else if (!scope.nodeIds.includes(this.repo.rootId()) || scope.nodeIds.length !== this.repo.list('tree_nodes', n => !n.deleted).length) fail('BROWSE_SCOPE', 'Global metrics require the whole tree scope', 403); shape(payload.range ?? {}, [], ['afterOrdinal', 'throughOrdinal']);
|
|
81
|
+
const current = this.metrics.freeze(payload.scopeNodeId ?? null, payload.range), windows = this.repo.list('tree_metric_windows', w => !w.needsRecheck && w.scope === current.scope && w.toOrdinal <= current.toOrdinal).sort((a, b) => a.toOrdinal - b.toOrdinal).slice(-2);
|
|
82
|
+
return { ...current, windows, comparison: this.metrics.compare(windows[0], windows[1]) }; }
|
|
83
|
+
if (endpoint === 'reports.list') {
|
|
84
|
+
shape(payload, [], ['cursor', 'limit']); this.requireReports(scope); const page = this.page(this.repo.list('tree_reports'), payload, 'reports'); return { reports: page.rows.map(r => this.reports.preview(r.id, { actor: context.actor })), nextCursor: page.nextCursor };
|
|
85
|
+
}
|
|
86
|
+
if (endpoint === 'reports.details') { shape(payload, ['reportId'], ['includeEvidence']); this.requireReports(scope); if (payload.includeEvidence !== undefined && typeof payload.includeEvidence !== 'boolean') fail('INVALID_FIELD', 'Explicit evidence choice required'); return this.reports.preview(payload.reportId, { actor: context.actor, includeEvidence: payload.includeEvidence ?? false }); }
|
|
87
|
+
if (endpoint === 'source.read') { shape(payload, ['itemId','version','sourceIndex']); const item = this.repo.get('tree_items', payload.itemId); if (!item) fail('ITEM_NOT_FOUND', 'Item missing'); this.scoped(item.nodeId, scope); const version = this.repo.get('tree_item_versions', versionId(item.id, payload.version)); integer(payload.sourceIndex, 'sourceIndex', 0, 1999); const ref = version?.sourceRefs[payload.sourceIndex], source = ref && this.resolveSource(ref); if (!source) fail('SOURCE_CHANGED', 'Evidence is no longer available'); return { role: source.role, origin: source.origin, text: source.text, ref }; }
|
|
88
|
+
if (endpoint === 'snapshots.list') { shape(payload, ['logicalTurnId']); const turn = this.repo.get('tree_turns', payload.logicalTurnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Snapshot turn is outside scope', 403); return { snapshots: this.repo.list('tree_snapshots', s => s.logicalTurnId === turn.id).map(s => ({ id: s.id, coverage: s.coverage, restoreState: s.restoreState, capturedAt: s.capturedAt })) }; }
|
|
89
|
+
if (endpoint === 'receipts.read') {
|
|
90
|
+
shape(payload, ['requestId']); const value = this.repo.meta().uiRequests?.[this.key(context.actor, payload.requestId)]; if (!value) return { state: 'not_found' }; if (value.sessionId && value.sessionId !== context.sessionId) fail('BROWSE_SCOPE', '操作属于另一段对话', 403); value.nodeIds.forEach(id => { if (!scope.nodeIds.includes(id)) fail('BROWSE_SCOPE', 'Receipt belongs to another browse scope', 403); });
|
|
91
|
+
if (value.endpoint === 'snapshots.restore' && ['pending', 'unknown'].includes(value.state)) { const intent = this.repo.meta().restoreIntents?.[digest(['restore', value.domainRequestId])]; if (intent) return { ...value, state: intent.state === 'completed' ? 'success' : intent.state, value: intent }; }
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
fail('ENDPOINT_NOT_FOUND', 'Unknown tree endpoint', 404);
|
|
95
|
+
}
|
|
96
|
+
requireReports(scope) { if (!scope.nodeIds.includes(this.repo.rootId()) || scope.nodeIds.length !== this.repo.list('tree_nodes', n => !n.deleted).length) fail('BROWSE_SCOPE', 'Reports combine evidence across the tree', 403); }
|
|
97
|
+
key(actor, requestId) { text(requestId, 'requestId', 512); return digest(['tree-ui', actor.id, requestId]); }
|
|
98
|
+
recordUI(key, value) {
|
|
99
|
+
return this.repo.command('recordTreeUIReceipt', { requestId: digest([key, value.state, digest(value)]), expected: {}, input: { key, state: value.state } }, { kind: 'program', id: 'tree-ui' }, () => { const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, uiRequests: { ...meta.uiRequests, [key]: value } }, meta.revision); return value; }).result;
|
|
100
|
+
}
|
|
101
|
+
async dispatch(endpoint, payload, context) {
|
|
102
|
+
const scope = this.authorize(context); if (!TREE_ENDPOINTS.includes(endpoint)) fail('ENDPOINT_NOT_FOUND', 'Unknown tree endpoint', 404);
|
|
103
|
+
if (readEndpoints.has(endpoint)) return this.read(endpoint, payload, context, scope);
|
|
104
|
+
text(payload.requestId, 'requestId', 512); const key = this.key(context.actor, payload.requestId), hash = digest([endpoint, payload, ...(endpoint === 'session.selectBranch' ? [context.sessionId] : [])]), prior = this.repo.meta().uiRequests?.[key];
|
|
105
|
+
if (prior) { if (prior.hash !== hash) fail('IDEMPOTENCY_CONFLICT', 'This request identity has different input'); prior.nodeIds.forEach(id => { if (!scope.nodeIds.includes(id)) fail('BROWSE_SCOPE', 'Receipt outside browser scope', 403); }); return prior; }
|
|
106
|
+
const state = { endpoint, hash, nodeIds: [...scope.nodeIds], state: 'pending', domainRequestId: key, requestId: payload.requestId };
|
|
107
|
+
if (endpoint === 'session.selectBranch') {
|
|
108
|
+
shape(payload, ['requestId', 'nodeId', 'expectedTreeRevision', 'expectedSelectionRevision', 'pendingId']);
|
|
109
|
+
this.requireReports(scope);
|
|
110
|
+
if (!this.selectBranch) fail('ENDPOINT_UNAVAILABLE', '当前服务尚未启用目标范围选择');
|
|
111
|
+
if (payload.nodeId !== null) this.scoped(payload.nodeId, scope);
|
|
112
|
+
integer(payload.expectedTreeRevision, 'expectedTreeRevision'); integer(payload.expectedSelectionRevision, 'expectedSelectionRevision', 0);
|
|
113
|
+
if (payload.pendingId !== null) text(payload.pendingId, 'pendingId', 128);
|
|
114
|
+
state.sessionId = context.sessionId; this.recordUI(key, state);
|
|
115
|
+
try {
|
|
116
|
+
const value = await this.selectBranch(payload, context, key);
|
|
117
|
+
return this.recordUI(key, { ...state, state: 'success', value });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const pending = payload.pendingId && this.repo.meta().attributionPending?.[payload.pendingId];
|
|
120
|
+
this.recordUI(key, { ...state, state: pending?.continuationRequestId === key ? 'unknown' : 'failed', error: { code: error.code ?? 'HOST_ERROR' } }); throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (endpoint.startsWith('snapshots.')) {
|
|
124
|
+
if (!this.snapshotsFor) fail('SNAPSHOT_UNAVAILABLE', 'Workspace snapshot service is not mounted');
|
|
125
|
+
shape(payload, endpoint === 'snapshots.preview' ? ['requestId', 'snapshotId', 'paths'] : ['requestId', 'previewId', 'expectedHashes']);
|
|
126
|
+
const preview = endpoint === 'snapshots.restore' && this.repo.meta().restorePreviews?.[payload.previewId], snapshot = this.repo.get('tree_snapshots', payload.snapshotId ?? preview?.snapshotId);
|
|
127
|
+
const turn = snapshot && this.repo.get('tree_turns', snapshot.logicalTurnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Snapshot is outside the admitted turn scope', 403);
|
|
128
|
+
state.nodeIds = [...turn.nodeIds]; this.recordUI(key, state); const snapshots = this.snapshotsFor(context);
|
|
129
|
+
try { const value = endpoint === 'snapshots.preview' ? await snapshots.preview({ snapshotId: snapshot.id, paths: payload.paths, requestId: key }) : await snapshots.apply({ previewId: payload.previewId, expectedCurrentHashes: payload.expectedHashes, requestId: key, signal: context.signal });
|
|
130
|
+
if (endpoint === 'snapshots.preview') { value.diffs = await this.previewDiffs(value, snapshots, context.signal); this.authorize(context); }
|
|
131
|
+
return this.recordUI(key, { ...state, state: endpoint === 'snapshots.preview' || value.state === 'completed' ? 'success' : value.state ?? 'unknown', value });
|
|
132
|
+
} catch (error) { this.recordUI(key, { ...state, state: 'unknown', error: { code: error.code ?? 'SNAPSHOT_UNKNOWN' } }); throw error; }
|
|
133
|
+
}
|
|
134
|
+
return this.repo.transaction(() => {
|
|
135
|
+
const beforeNodes = this.repo.list('tree_nodes'), result = this.mutate(endpoint, payload, context, scope, key), receipt = result.receipt ?? result;
|
|
136
|
+
this.metrics.observeTree(receipt, beforeNodes);
|
|
137
|
+
if (result.repair) this.recordRepair(receipt, result.repair);
|
|
138
|
+
if (endpoint !== 'reports.export') state.nodeIds = uniqueIds([...(result.repair?.nodeIds ?? []), ...receipt.eventIds.flatMap(id => this.repo.get('tree_change_events', id).entityIds).flatMap(key => {
|
|
139
|
+
const split = key.indexOf(':'), kind = key.slice(0, split), row = this.repo.get(kind, key.slice(split + 1));
|
|
140
|
+
return kind === 'tree_nodes' ? [row.id] : kind === 'tree_turns' ? row.nodeIds : row?.nodeId ? [row.nodeId] : row?.scopeNodeId ? [row.scopeNodeId] : [];
|
|
141
|
+
})]).filter(id => !this.repo.get('tree_nodes', id)?.deleted);
|
|
142
|
+
return this.recordUI(key, { ...state, state: 'success', value: receipt });
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
mutate(endpoint, p, context, scope, requestId) {
|
|
146
|
+
const opts = { repository: this.repo, resolveSource: this.resolveSource, context: () => ({ actor: context.actor }) }, tree = new TreeService(opts), items = new ItemService(opts), command = (input, expected = {}) => ({ requestId, input, expected });
|
|
147
|
+
const node = id => this.scoped(id, scope), expected = (kind, id, value) => { integer(value, 'expectedRevision'); return { [kind + ':' + id]: value }; };
|
|
148
|
+
if (endpoint === 'item.undoCorrection') {
|
|
149
|
+
shape(p, ['requestId', 'itemId', 'expectedRevision', 'replacementId', 'expectedReplacementRevision']);
|
|
150
|
+
const original = items.item(p.itemId), replacement = items.item(p.replacementId);
|
|
151
|
+
node(original.nodeId); node(replacement.nodeId);
|
|
152
|
+
if (original.kind !== 'understanding' || !['invalid', 'changed'].includes(original.lifecycle) || original.replacedBy !== replacement.id ||
|
|
153
|
+
original.nodeId !== replacement.nodeId || replacement.lifecycle !== 'active' || replacement.kind !== 'understanding' ||
|
|
154
|
+
replacement.activeVersion !== 1 || replacement.userProtected || replacement.producerRole !== 'learning') {
|
|
155
|
+
fail('CORRECTION_CHANGED', '更正后的内容已经变化,请先查看历史,不能直接撤销');
|
|
156
|
+
}
|
|
157
|
+
const old = items.version(original.id, original.activeVersion);
|
|
158
|
+
for (const ref of old.sourceRefs) {
|
|
159
|
+
if (!this.resolveSource(ref)) fail('SOURCE_CHANGED', '原理解的来源已不可用,不能直接恢复');
|
|
160
|
+
const owners = this.repo.list('tree_turns', turn => (turn.experienceRefs ?? turn.sourceRefs).some(source => digest(source) === digest(ref)));
|
|
161
|
+
if (owners.length && !owners.some(turn => turn.status === 'settled' && turn.nodeIds.includes(original.nodeId))) fail('SOURCE_CHANGED', '原理解的来源已不属于此事项');
|
|
162
|
+
}
|
|
163
|
+
const revisions = { ...expected('tree_items', original.id, p.expectedRevision), ...expected('tree_items', replacement.id, p.expectedReplacementRevision) };
|
|
164
|
+
return this.repo.command('undoLearningCorrection', command({ itemId: original.id, replacementId: replacement.id }, revisions), context.actor, () => {
|
|
165
|
+
this.repo.save('tree_items', { ...original, lifecycle: 'active', replacedBy: null,
|
|
166
|
+
correctionUndoHistory: [...(original.correctionUndoHistory ?? []), { replacementId: replacement.id, correctionSourceRefs: original.correctionSourceRefs ?? [], requestId }] }, original.revision);
|
|
167
|
+
this.repo.save('tree_items', { ...replacement, lifecycle: 'withdrawn', withdrawalReason: '用户撤销自动更正' }, replacement.revision);
|
|
168
|
+
for (const exposure of this.repo.list('tree_exposures', value => !value.revoked && value.itemVersions.some(version => version.itemId === replacement.id))) {
|
|
169
|
+
this.repo.save('tree_exposures', { ...exposure, revoked: true, invalidationReason: 'user_undid_correction' }, exposure.revision);
|
|
170
|
+
}
|
|
171
|
+
return { itemId: original.id, replacementId: replacement.id };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (endpoint === 'turn.undoStatus') {
|
|
175
|
+
shape(p, ['requestId', 'turnId', 'observationId', 'expectedRevisions']); const turn = this.repo.get('tree_turns', p.turnId); if (!turn) fail('TURN_NOT_FOUND', 'Turn not found'); turn.nodeIds.forEach(node);
|
|
176
|
+
const all = Object.values(this.repo.meta().treeObservations ?? {}).flat(), original = all.find(o => o.id === p.observationId && o.logicalTurnId === turn.id && o.type === 'status' && o.origin !== 'cascade');
|
|
177
|
+
if (!original || this.repo.meta().reversedTreeChanges?.[original.commandReceiptId]) fail('REVISION_CONFLICT', 'Status change is unavailable or already reversed');
|
|
178
|
+
const group = all.filter(o => o.commandReceiptId === original.commandReceiptId && o.type === 'status'); shape(p.expectedRevisions, group.map(o => o.nodeId));
|
|
179
|
+
for (const o of group) { const current = node(o.nodeId); integer(p.expectedRevisions[o.nodeId], 'expectedRevision'); if (current.revision !== p.expectedRevisions[o.nodeId] || current.status !== o.after || current.deleted || all.some(later => later.ordinal > o.ordinal && later.nodeId === o.nodeId && ['status', 'parentId', 'deleted'].includes(later.type))) fail('REVISION_CONFLICT', 'This status has changed since the original action'); }
|
|
180
|
+
for (const o of group.filter(o => o.before !== 'active')) if (this.repo.list('tree_nodes', n => !n.deleted && !group.some(g => g.nodeId === n.id) && ['active', 'paused'].includes(n.status) && this.repo.ancestors(n.id).some(a => a.id === o.nodeId)).length) fail('REVISION_CONFLICT', 'Later descendant activity prevents restoring this old status');
|
|
181
|
+
const receipt = this.repo.command('undoTurnStatus', command({ turnId: turn.id, observationId: original.id }, Object.fromEntries(group.map(o => ['tree_nodes:' + o.nodeId, p.expectedRevisions[o.nodeId]]))), context.actor, () => {
|
|
182
|
+
for (const o of group) { const current = node(o.nodeId); this.repo.save('tree_nodes', { ...current, status: o.before, statusOrigin: o.beforeStatusOrigin, cascadeFromId: o.beforeCascadeFromId, statusChangedAt: this.repo.now(), statusSourceRefs: [] }, current.revision); }
|
|
183
|
+
const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, treeRevision: meta.treeRevision + 1, reversedTreeChanges: { ...meta.reversedTreeChanges, [original.commandReceiptId]: requestId } }, meta.revision); return { turnId: turn.id, nodeIds: group.map(o => o.nodeId), reversalOf: original.commandReceiptId };
|
|
184
|
+
});
|
|
185
|
+
return { receipt, repair: { producerRole: original.origin === 'attribution' ? 'attribution' : 'main', producerJobId: null, nodeIds: group.map(o => o.nodeId), type: 'user_undo_status', reason: '用户撤销本轮状态判断;等待诊断是否属于原误判', eligible: original.origin !== 'user' } };
|
|
186
|
+
}
|
|
187
|
+
if (endpoint === 'node.rename') { shape(p, ['requestId','nodeId','name','expectedRevision']); node(p.nodeId); text(p.name, 'name', 80);
|
|
188
|
+
const before = node(p.nodeId), receipt = this.repo.command('renameTreeNode', command({ nodeId: p.nodeId, name: p.name }, expected('tree_nodes', p.nodeId, p.expectedRevision)), context.actor, () => { const n = node(p.nodeId); this.repo.save('tree_nodes', { ...n, name: p.name, nameUserProtected: true }, n.revision); const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, treeRevision: meta.treeRevision + 1 }, meta.revision); return { nodeId: n.id }; });
|
|
189
|
+
return { receipt, repair: { producerRole: 'attribution', producerJobId: null, nodeIds: [p.nodeId], type: 'user_rename', reason: '用户修改事项名称;仍需判断是原误判还是新的意图', eligible: !before.nameUserProtected && before.name !== p.name } }; }
|
|
190
|
+
if (endpoint === 'node.move') { shape(p, ['requestId','nodeId','parentId','expectedTreeRevision']); const n = node(p.nodeId); node(p.parentId); this.requireSubtree(n.id, scope); if (this.repo.meta().treeRevision !== p.expectedTreeRevision) fail('REVISION_CONFLICT', 'Tree changed'); return tree.moveNode(command({ nodeId: n.id, parentId: p.parentId, sourceRefs: [] }, expected('tree_nodes', n.id, n.revision))); }
|
|
191
|
+
if (endpoint === 'node.status') { shape(p, ['requestId','nodeId','status','expectedRevision']); const before = node(p.nodeId); this.requireSubtree(p.nodeId, scope); if (p.status === 'active') this.repo.ancestors(p.nodeId).forEach(n => node(n.id));
|
|
192
|
+
const receipt = tree.setStatus(command({ nodeId: p.nodeId, status: p.status, sourceRefs: [] }, expected('tree_nodes', p.nodeId, p.expectedRevision)));
|
|
193
|
+
return { receipt, repair: { producerRole: before.statusOrigin === 'model' ? 'main' : 'attribution', producerJobId: null, nodeIds: [p.nodeId], type: 'user_status_change', reason: '用户改变事项状态;重新打开助手标完事项需要回看依据', eligible: before.statusOrigin !== 'user' && before.status !== p.status } }; }
|
|
194
|
+
if (['turn.correct', 'turn.undoCreate'].includes(endpoint)) {
|
|
195
|
+
shape(p, endpoint === 'turn.correct' ? ['requestId','turnId','nodeIds','expectedAssignmentRevision'] : ['requestId','turnId','nodeId','expectedRevision','expectedAssignmentRevision'], endpoint === 'turn.correct' ? ['create'] : []);
|
|
196
|
+
const turn = this.repo.get('tree_turns', p.turnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Turn outside scope', 403); if (turn.assignmentRevision !== p.expectedAssignmentRevision) fail('REVISION_CONFLICT', 'Assignment changed');
|
|
197
|
+
let targetIds = p.nodeIds;
|
|
198
|
+
if (endpoint === 'turn.undoCreate') { const n = node(p.nodeId); if (n.revision !== p.expectedRevision || !turn.createdNodeIds.includes(n.id)) fail('REVISION_CONFLICT', 'Created node changed');
|
|
199
|
+
if (this.repo.list('tree_links', l => l.nodeId === n.id && l.active && l.turnId !== turn.id).length || this.repo.list('tree_nodes', c => c.parentId === n.id && !c.deleted).length) fail('NEED_ASSIGNMENT_CORRECTION', 'This node has later experience; correct this turn instead');
|
|
200
|
+
targetIds = turn.nodeIds.filter(id => id !== n.id); if (!targetIds.length) targetIds = [n.parentId]; }
|
|
201
|
+
array(targetIds, 'nodeIds', p.create ? 7 : 8).forEach(node);
|
|
202
|
+
if (p.create) {
|
|
203
|
+
shape(p.create, ['name', 'parentId', 'expectedTreeRevision']); text(p.create.name, 'name', 80); node(p.create.parentId);
|
|
204
|
+
if (p.create.expectedTreeRevision !== this.repo.meta().treeRevision) fail('REVISION_CONFLICT', 'Tree changed');
|
|
205
|
+
this.repo.ancestors(p.create.parentId).forEach(n => node(n.id)); const id = randomUUID(), beforeCreate = this.repo.list('tree_nodes');
|
|
206
|
+
const created = this.repo.command('createCorrectionTarget', { requestId: digest([requestId, 'create']), expected: {}, input: p.create }, context.actor, () => {
|
|
207
|
+
this.repo.save('tree_nodes', { id, parentId: p.create.parentId, name: p.create.name, nameUserProtected: true, status: 'active', statusOrigin: 'user', sourceRefs: [], lastOwnTurnAt: turn.startedAt, lastSummaryWatermark: null });
|
|
208
|
+
for (const ancestor of this.repo.ancestors(p.create.parentId)) if (ancestor.status !== 'active') this.repo.save('tree_nodes', { ...ancestor, status: 'active', statusOrigin: 'user', cascadeFromId: null }, ancestor.revision);
|
|
209
|
+
const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, treeRevision: meta.treeRevision + 1 }, meta.revision); return { nodeId: id };
|
|
210
|
+
});
|
|
211
|
+
this.metrics.observeTree(created, beforeCreate); targetIds = [...targetIds, id];
|
|
212
|
+
}
|
|
213
|
+
const receipt = tree.correctAssignment(command({ turnId: turn.id, nodeIds: targetIds, sourceRefs: [], ...(endpoint === 'turn.undoCreate' ? { undoCreate: true } : {}) }, expected('tree_turns', turn.id, turn.revision)));
|
|
214
|
+
return { receipt, repair: { producerRole: 'attribution', producerJobId: turn.attributionRunId, nodeIds: uniqueIds([...turn.nodeIds, ...targetIds]), type: 'assignment_correction', reason: '用户纠正本轮目标归属', eligible: !receipt.result.unchanged } };
|
|
215
|
+
}
|
|
216
|
+
if (endpoint.startsWith('item.')) {
|
|
217
|
+
shape(p, endpoint === 'item.edit' ? ['requestId','itemId','expectedVersion','replacement','reason'] : endpoint === 'item.withdraw' ? ['requestId','itemId','expectedVersion','reason'] : ['requestId','itemId','expectedVersion']);
|
|
218
|
+
const item = items.item(p.itemId); node(item.nodeId); if ((item.activeVersion ?? item.draftVersion) !== p.expectedVersion) fail('REVISION_CONFLICT', 'Item version changed');
|
|
219
|
+
const old = items.version(item.id, p.expectedVersion), exp = expected('tree_items', item.id, item.revision); let receipt;
|
|
220
|
+
if (endpoint === 'item.confirm') {
|
|
221
|
+
if (item.stale) fail('ITEM_RECHECK_REQUIRED', '归属已变化,请核对并保存当前内容后再使用');
|
|
222
|
+
if (item.kind !== 'understanding' || old.sourceKind !== 'assistant_inferred') fail('CONFIRM_INFERENCE_ONLY', 'Only an assistant inference can be confirmed');
|
|
223
|
+
receipt = items.confirm(command({ itemId: item.id, version: p.expectedVersion }, exp));
|
|
224
|
+
new EpisodeSignals({ repository: this.repo, resolveSource: this.resolveSource }).captureConfirmation(receipt.id); return receipt;
|
|
225
|
+
}
|
|
226
|
+
if (endpoint === 'item.withdraw') receipt = items.withdraw(command({ itemId: item.id, version: p.expectedVersion, reason: p.reason }, exp));
|
|
227
|
+
else receipt = this.editItem(command({ itemId: item.id, version: p.expectedVersion, replacement: p.replacement, reason: p.reason }, exp), item, old, context.actor);
|
|
228
|
+
return { receipt, repair: { producerRole: item.producerRole ?? 'learning', producerJobId: item.producerJobId ?? null, itemId: item.id, itemVersion: p.expectedVersion, nodeIds: [item.nodeId], type: endpoint === 'item.edit' ? 'user_edit' : 'user_withdraw', reason: p.reason, eligible: !item.stale && !item.userProtected && old.sourceKind === 'assistant_inferred' } };
|
|
229
|
+
}
|
|
230
|
+
if (endpoint === 'hook.disable') { shape(p, ['requestId','methodId','hookId','expectedVersion']); const item = items.item(p.methodId); node(item.nodeId); if (item.activeVersion !== p.expectedVersion) fail('REVISION_CONFLICT', 'Method changed');
|
|
231
|
+
const hook = this.repo.get('tree_hooks', digest(['hook_version', p.hookId, p.expectedVersion])); if (!hook || hook.methodId !== item.id) fail('HOOK_NOT_FOUND', 'Hook is not part of the current method');
|
|
232
|
+
const receipt = this.repo.command('disableTreeHook', command({ methodId: item.id, hookId: p.hookId, version: p.expectedVersion }, expected('tree_hooks', hook.id, hook.revision)), context.actor, () => { this.repo.save('tree_hooks', { ...hook, enabled: false, manuallyDisabled: true }, hook.revision); return { methodId: item.id, hookId: hook.hookId, enabled: false }; });
|
|
233
|
+
return { receipt, repair: { producerRole: item.producerRole ?? 'learning', producerJobId: item.producerJobId ?? null, nodeIds: [item.nodeId], itemId: item.id, itemVersion: item.activeVersion, type: 'user_disable_hook', reason: '用户停用方法提醒;尚未诊断原因', eligible: hook.enabled && !item.userProtected } }; }
|
|
234
|
+
if (endpoint === 'signal.correct') { shape(p, ['requestId','signalId','category','expectedRevision']); oneOf(p.category, CATEGORIES, 'category'); const old = this.repo.get('tree_signals', p.signalId); if (!old || !effectiveSignals(this.repo, this.resolveSource).some(s => s.id === old.id)) fail('REVISION_CONFLICT', 'Signal has already been corrected'); old.nodeIds.forEach(node);
|
|
235
|
+
const replacement = Object.fromEntries(['eventId','subjectRefs','dimension','subitem','category','polarity','evidenceKind','sourceRefs','nodeIds','confidence'].map(k => [k, old[k]]));
|
|
236
|
+
const receipt = new SignalService(opts).correct(command({ signalId: old.id, replacement: { ...replacement, category: p.category, subitem: p.category, polarity: signalPolarity(p.category) } }, expected('tree_signals', old.id, p.expectedRevision)));
|
|
237
|
+
return { receipt, repair: { producerRole: 'learning', producerJobId: null, nodeIds: old.nodeIds, type: 'user_category_correction', reason: '用户纠正反馈类别', eligible: !old.correctionOf && old.category !== p.category } }; }
|
|
238
|
+
if (endpoint === 'parameters.set') { shape(p, ['requestId','name','nodeId','value','expectedRevision']); node(p.nodeId); const id = digest(['parameter', p.name, p.nodeId]), old = this.repo.get('tree_parameters', id); if ((old?.revision ?? 0) !== p.expectedRevision) fail('REVISION_CONFLICT', 'Parameter changed'); return tree.setParameter(command({ name: p.name, scopeNodeId: p.nodeId, value: p.value, sourceRefs: [] }, old ? expected('tree_parameters', id, p.expectedRevision) : {})); }
|
|
239
|
+
if (endpoint === 'reports.export') { shape(p, ['requestId','reportIds','includeEvidence','expectedRevisions']); this.requireReports(scope); const exp = {}; for (const id of array(p.reportIds, 'reportIds', 100)) Object.assign(exp, expected('tree_reports', id, p.expectedRevisions[id])); return this.reports.export(command({ reportIds: p.reportIds, includeEvidence: p.includeEvidence }, exp), context.actor); }
|
|
240
|
+
fail('ENDPOINT_NOT_FOUND', 'Unknown tree mutation', 404);
|
|
241
|
+
}
|
|
242
|
+
editItem(command, item, old, actor) {
|
|
243
|
+
if (item.lifecycle !== 'active') fail('VERSION_CONFLICT', 'Only active content may be edited; unfinished drafts can be withdrawn');
|
|
244
|
+
text(command.input.reason, 'reason'); const replacement = command.input.replacement;
|
|
245
|
+
if (item.kind === 'understanding') text(replacement, 'understanding', 2000);
|
|
246
|
+
else if (item.kind === 'supplement') validateShort(replacement);
|
|
247
|
+
else { shape(replacement, ['short', 'long']); if (!replacement.short && !replacement.long) fail('TEXT_REQUIRED', 'A method needs text'); if (replacement.short) validateShort(replacement.short); if (replacement.long) validateLong(replacement.long); }
|
|
248
|
+
return this.repo.command('editTreeItem', command, actor, () => {
|
|
249
|
+
const sourceRefs = item.stale ? old.sourceRefs.filter(ref => {
|
|
250
|
+
const owners = this.repo.list('tree_turns', t => (t.experienceRefs ?? t.sourceRefs).some(r => digest(r) === digest(ref)));
|
|
251
|
+
return !owners.length || owners.some(t => t.nodeIds.includes(item.nodeId) && t.status === 'settled');
|
|
252
|
+
}) : old.sourceRefs;
|
|
253
|
+
const version = old.version + 1, content = item.kind === 'method' ? { ...old.content, ...replacement, sourceRefs } : replacement;
|
|
254
|
+
this.repo.save('tree_item_versions', { ...old, id: versionId(item.id, version), version, content, sourceRefs, sourceKind: 'user_expressed', reason: command.input.reason, supersedes: old.version, userEditRef: digest(['change', command.requestId]) });
|
|
255
|
+
if (item.kind === 'method') {
|
|
256
|
+
const bundle = this.repo.get('tree_methods', methodId(item.id, old.version)), hooks = bundle.hooks.map(ref => { const row = this.repo.get('tree_hooks', digest(['hook_version', ref.id, ref.version])); this.repo.save('tree_hooks', { ...row, enabled: false }, row.revision); this.repo.save('tree_hooks', { ...row, id: digest(['hook_version', ref.id, version]), version }); return { id: ref.id, version }; });
|
|
257
|
+
this.repo.save('tree_methods', { ...bundle, id: methodId(item.id, version), version, ...replacement, hooks, evaluation: { status: 'unverified', windowId: null, evidenceIds: [] } });
|
|
258
|
+
} else if (item.kind === 'supplement') { const bundle = this.repo.get('tree_supplements', digest(['supplement', item.id, old.version])); this.repo.save('tree_supplements', { ...bundle, id: digest(['supplement', item.id, version]), version, ...(item.stale ? { traitEvidence: bundle.traitEvidence.filter(ref => sourceRefs.some(r => digest(r) === digest(ref))) } : {}) }); }
|
|
259
|
+
this.repo.save('tree_items', { ...item, activeVersion: version, validation: 'unverified', userProtected: true, producerRole: 'user', producerJobId: null,
|
|
260
|
+
...(item.stale ? { stale: false, staleReason: null, sourceReview: { ...item.sourceReview, status: 'resolved_by_user', userEditRef: digest(['change', command.requestId]) } } : {}) }, item.revision);
|
|
261
|
+
for (const e of this.repo.list('tree_exposures', e => !e.revoked && e.itemVersions.some(v => v.itemId === item.id))) this.repo.save('tree_exposures', { ...e, revoked: true, invalidationReason: 'user_edit' }, e.revision);
|
|
262
|
+
return { itemId: item.id, version };
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
recordRepair(receipt, value) {
|
|
266
|
+
const event = this.repo.get('tree_change_events', receipt.eventIds[0]), ref = { sessionId: 'arcfold-ui:' + this.repo.rootId(), seq: event.ordinal, sha256: digest(event) };
|
|
267
|
+
const id = digest(['ui-repair', receipt.id]);
|
|
268
|
+
this.repo.command('recordUserCorrectionObservation', { requestId: id, expected: {}, input: { receiptId: receipt.id, sourceRefs: [ref] } }, { kind: 'program', id: 'tree-ui' }, () => {
|
|
269
|
+
this.repo.save('tree_repairs', { id, producerRole: value.producerRole, producerJobId: value.producerJobId, itemId: value.itemId ?? null, itemVersion: value.itemVersion ?? null, stage: 'generate', type: value.type, sourceSignalIds: [], nodeIds: value.nodeIds, reason: value.reason, requestId: receipt.requestId, evidenceRefs: [ref], countsAsError: value.eligible ? null : false, eligibleForDiagnosis: value.eligible, attribution: 'source_bound_observation' }); return { repairId: id };
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
requireSubtree(id, scope) { for (const n of this.repo.list('tree_nodes', n => !n.deleted && this.repo.ancestors(n.id).some(a => a.id === id))) this.scoped(n.id, scope); }
|
|
273
|
+
async previewDiffs(preview, snapshots, signal) {
|
|
274
|
+
const scan = await snapshots.io.scan({ copy: true, signal });
|
|
275
|
+
const render = bytes => bytes === null ? null : { sha256: contentHash(bytes), size: bytes.length, binary: bytes.includes(0), truncated: bytes.length > 65536, text: bytes.includes(0) ? null : bytes.subarray(0, 65536).toString('utf8') };
|
|
276
|
+
return preview.changes.map(change => {
|
|
277
|
+
const current = scan.manifest.files.find(f => f.path === change.path), fingerprint = current ? { sha256: current.sha256, mode: current.mode } : null;
|
|
278
|
+
if (canonicalJson(fingerprint) !== canonicalJson(change.current)) fail('RESTORE_CONFLICT', 'Files changed while preparing the review');
|
|
279
|
+
const target = change.target ? snapshots.git(['cat-file', 'blob', change.target.blob], undefined, true) : null;
|
|
280
|
+
if (target && contentHash(target) !== change.target.sha256) fail('SNAPSHOT_HASH', 'Saved content changed');
|
|
281
|
+
return { path: change.path, kind: change.kind, current: render(scan.contents.get(change.path) ?? null), restoreTo: render(target) };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const uniqueIds = values => [...new Set(values)];
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { CONTRACT, canonicalJson, digest, fail, jsonClone } from './contracts.mjs';
|
|
2
|
+
import { validateOutput, resolveSourceIds } from './prompts.mjs';
|
|
3
|
+
import { effectiveSignals, EpisodeSignals } from './signals.mjs';
|
|
4
|
+
import { itemInput } from './learning.mjs';
|
|
5
|
+
import { LocalReports } from './reports.mjs';
|
|
6
|
+
|
|
7
|
+
const unique = values => [...new Map(values.map(v => [canonicalJson(v), v])).values()];
|
|
8
|
+
const versionKey = (id, version) => digest(['item_version', id, version]);
|
|
9
|
+
|
|
10
|
+
export class VerificationService {
|
|
11
|
+
constructor({ repository, experience, contextBuilder, jobs, modelConfig, clusters, writerRouter }) {
|
|
12
|
+
Object.assign(this, { repo: repository, experience, builder: contextBuilder, jobs, config: modelConfig, clusters, writers: writerRouter }); this.resolveSource = contextBuilder.resolveSource; this.reports = new LocalReports({ repository, resolveSource: this.resolveSource });
|
|
13
|
+
}
|
|
14
|
+
exposureTurn(exposure) {
|
|
15
|
+
const invocation = this.repo.get('tree_invocations', exposure.invocationId) ?? this.repo.list('tree_invocations', i => i.ledgerRequestId === exposure.invocationId)[0];
|
|
16
|
+
if (!invocation || invocation.role !== 'main' || invocation.nativeSessionId !== exposure.modelSessionId) return null;
|
|
17
|
+
return this.repo.list('tree_turns', t => t.sessionId === invocation.nativeSessionId && t.nativeTurn === invocation.nativeTurn && t.status === 'settled')[0] ?? null;
|
|
18
|
+
}
|
|
19
|
+
freeze(nodeId, { reason = 'window', summaryId = null, clusterIds = [], afterTurnId = null } = {}) {
|
|
20
|
+
if (!['window', 'cluster', 'stagnation'].includes(reason)) fail('VERIFICATION_TRIGGER', 'Unsupported reflection trigger');
|
|
21
|
+
const node = this.builder.node(nodeId);
|
|
22
|
+
const clusterViews = clusterIds.map(id => { const row = this.repo.get('tree_clusters', id); if (!row || row.nodeId !== nodeId) fail('CLUSTER_SCOPE', 'Cluster must be reviewed at its common ancestor'); return this.clusters.freeze(row); });
|
|
23
|
+
const clusterRefs = clusterViews.flatMap(c => c.episodes.flatMap(e => [...e.beforeRefs, ...e.reactionRefs, ...e.resolutionRefs]));
|
|
24
|
+
const clusterTurns = this.repo.list('tree_turns', t => t.status === 'settled' && (t.experienceRefs ?? t.sourceRefs).some(r => clusterRefs.some(ref => digest(ref) === digest(r))));
|
|
25
|
+
const ownTurns = [...new Map([...this.experience.turns(nodeId, { includeInterrupted: false }), ...clusterTurns].map(t => [t.id, t])).values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.sessionId.localeCompare(b.sessionId) || a.nativeTurn - b.nativeTurn);
|
|
26
|
+
const start = afterTurnId === null ? 0 : ownTurns.findIndex(t => t.id === afterTurnId) + 1;
|
|
27
|
+
if (afterTurnId !== null && start === 0) fail('WINDOW_BOUNDARY', 'Unknown prior window boundary');
|
|
28
|
+
const turns = ownTurns.slice(start), ids = new Set(turns.map(t => t.id));
|
|
29
|
+
const exposures = this.repo.list('tree_exposures', e => e.role === 'main').filter(e => { const turn = this.exposureTurn(e); return turn && ids.has(turn.id); });
|
|
30
|
+
const signals = effectiveSignals(this.repo, this.resolveSource), allTurns = this.repo.list('tree_turns');
|
|
31
|
+
const feedbackScopes = new Set([nodeId, ...clusterViews.flatMap(c => c.episodes.flatMap(e => e.nodeIds))]);
|
|
32
|
+
const effects = turns.map(turn => {
|
|
33
|
+
const material = this.experience.material(turn); if (material.revokedRefs.length) fail('SOURCE_CHANGED', 'Verification cannot silently omit withdrawn experience');
|
|
34
|
+
const next = allTurns.filter(t => t.sessionId === turn.sessionId && t.nativeTurn > turn.nativeTurn).sort((a, b) => a.nativeTurn - b.nativeTurn)[0];
|
|
35
|
+
const feedback = (next?.nodeIds.some(id => feedbackScopes.has(id)) ? next.userSourceRefs : []).map(this.resolveSource).filter(Boolean);
|
|
36
|
+
const observations = exposures.filter(e => this.exposureTurn(e).id === turn.id);
|
|
37
|
+
return { turnId: turn.id, assignmentRevision: turn.assignmentRevision, exposureIds: observations.map(e => e.id), itemVersions: unique(observations.flatMap(e => e.visibleItemVersions ?? e.itemVersions)),
|
|
38
|
+
input: material.sources.filter(s => s.role === 'user' && s.origin === 'user'), answer: material.sources.filter(s => s.role === 'assistant' && s.origin === 'model'), feedback,
|
|
39
|
+
signals: signals.filter(s => s.sourceRefs.some(ref => feedback.some(source => digest(ref) === digest(source.ref)))),
|
|
40
|
+
uiActions: this.repo.list('tree_change_events', e => e.actor.kind === 'user' && e.entityIds.some(key => key === 'tree_turns:' + turn.id)) };
|
|
41
|
+
});
|
|
42
|
+
const summary = summaryId ? this.repo.get('tree_summaries', summaryId) : null;
|
|
43
|
+
if (summaryId && (!summary || summary.nodeId !== nodeId)) fail('SUMMARY_SCOPE', 'Window needs its own exact summary');
|
|
44
|
+
const before = summary?.previousVersion ? this.repo.get('tree_summaries', digest(['summary', nodeId, summary.previousVersion])) : null;
|
|
45
|
+
const context = this.builder.build([nodeId], 'verification'), exposedIds = new Set(exposures.flatMap(e => e.itemVersions.map(v => v.itemId)));
|
|
46
|
+
const reviewNodeIds = [...new Set([nodeId, ...clusterViews.flatMap(c => c.episodes.flatMap(e => e.nodeIds))])];
|
|
47
|
+
const extraRows = clusterViews.length ? this.builder.activeItems([...new Set(exposures.flatMap(e => e.nodeIds))], 'verification').filter(r => exposedIds.has(r.item.id)) : [];
|
|
48
|
+
const rows = [...new Map([...this.builder.activeItems(context.ancestorPaths.flat(), 'verification'), ...extraRows].filter(r => ['understanding', 'method'].includes(r.item.kind)).map(r => [r.item.id, r])).values()];
|
|
49
|
+
const sources = unique([...effects.flatMap(e => [...e.input, ...e.answer, ...e.feedback]), ...clusterViews.flatMap(c => [...c.episodes, ...c.controls].flatMap(e => e.sources))]);
|
|
50
|
+
const knownItems = rows.map(itemInput), trace = knownItems.map(item => ({ ...item, usedTurns: effects.filter(e => e.itemVersions.some(v => v.itemId === item.id && v.version === item.version)).map(e => e.turnId),
|
|
51
|
+
expectation: item.method?.expectation ?? null, inputMode: item.method ? item.method.short ? 'short_rule' : exposures.some(e => e.methodReadVersions?.some(v => v.itemId === item.id && v.version === item.version)) ? 'full_method_read' : 'directory_only' : 'understanding',
|
|
52
|
+
categoryCount: new Set(effects.filter(e => e.itemVersions.some(v => v.itemId === item.id && v.version === item.version)).flatMap(e => e.signals.filter(s => s.category === item.method?.expectation?.category).map(s => s.eventId))).size }));
|
|
53
|
+
const window = { nodeId, reason, summaryId, summaryVersion: summary?.version ?? null, throughTurnIds: turns.map(t => t.id), effectsHash: digest(effects), clusterVersions: clusterViews.map(c => [c.id, c.revision]) };
|
|
54
|
+
window.consolidationWindow = summary ? digest(['consolidation-window', nodeId, summary.sourceWatermark ?? [summary.throughTurnIds, summary.assignmentVersions]]) : null;
|
|
55
|
+
const windowId = digest(['verification-window', window]);
|
|
56
|
+
const input = { inputVersion: CONTRACT, role: 'verification', scope: { nodeIds: [nodeId], ancestorPaths: unique([...context.ancestorPaths, ...reviewNodeIds.map(id => this.repo.ancestors(id).map(n => n.id))]), treeRevision: context.treeRevision }, sources, knownItems,
|
|
57
|
+
allowedIds: { nodeIds: [...new Set([...context.ancestorPaths.flat(), ...exposures.flatMap(e => e.nodeIds), ...knownItems.flatMap(i => i.originChain ?? [])])], subjectIds: knownItems.map(i => i.id), fileIds: [],
|
|
58
|
+
opportunitySourceIds: unique(effects.filter(e => e.exposureIds.length).flatMap(e => e.input.map(s => s.id))), clusters: clusterViews.map(c => ({ id: c.id, version: c.revision })),
|
|
59
|
+
nodeVersions: [{ id: nodeId, version: node.revision }], summaryVersions: summary ? [{ id: nodeId, version: summary.version }] : [] },
|
|
60
|
+
changeReceipts: [], window: { ...window, id: windowId }, effects, trace, exposures, reviewNodeIds, clusters: clusterViews, summaryBefore: before, summaryAfter: summary,
|
|
61
|
+
hookObservations: new EpisodeSignals({ repository: this.repo, resolveSource: this.resolveSource }).hookCandidates().filter(h => ids.has(h.logicalTurnId)),
|
|
62
|
+
constraints: context.segments.filter(s => s.nodeId !== null) };
|
|
63
|
+
const revisions = { ['tree_nodes:' + nodeId]: node.revision };
|
|
64
|
+
for (const id of reviewNodeIds) revisions['tree_nodes:' + id] = this.repo.get('tree_nodes', id).revision;
|
|
65
|
+
for (const turn of turns) revisions['tree_turns:' + turn.id] = turn.revision;
|
|
66
|
+
for (const row of rows) revisions['tree_items:' + row.item.id] = row.item.revision;
|
|
67
|
+
for (const row of exposures) revisions['tree_exposures:' + row.id] = row.revision;
|
|
68
|
+
for (const row of clusterViews) revisions['tree_clusters:' + row.id] = row.revision;
|
|
69
|
+
return { input, revisions };
|
|
70
|
+
}
|
|
71
|
+
enqueue(nodeId, options) {
|
|
72
|
+
const frozen = this.freeze(nodeId, options), key = frozen.input.window.id;
|
|
73
|
+
const existing = this.repo.get('tree_jobs', digest(['model_job', 'verification', key])); if (existing) return existing;
|
|
74
|
+
return this.jobs.enqueue({ role: 'verification', key, nodeIds: [nodeId], input: frozen.input, revisionSet: frozen.revisions, config: this.config('verification', { parentJobId: options?.parentJobId, nodeIds: [nodeId] }), trigger: { kind: options?.reason ?? 'window', producerRole: 'program', windowId: key, ...(options?.parentJobId ? { parentJobId: options.parentJobId } : {}) } });
|
|
75
|
+
}
|
|
76
|
+
enqueueCorrection(intent) {
|
|
77
|
+
const key = digest(['assignment-source-recheck', intent.id]);
|
|
78
|
+
const existing = this.repo.get('tree_jobs', digest(['model_job', 'verification', key]));
|
|
79
|
+
// A failed or unknown request remains this intent's owner. Never recreate
|
|
80
|
+
// it with a newer prompt, model selection or item revision on restart.
|
|
81
|
+
if (existing) return existing;
|
|
82
|
+
const item = this.repo.get('tree_items', intent.input.itemId), turn = this.repo.get('tree_turns', intent.input.turnId);
|
|
83
|
+
if (!item || item.lifecycle !== 'active' || !item.stale || item.staleReason !== 'assignment_corrected' || item.activeVersion !== intent.input.itemVersion ||
|
|
84
|
+
turn?.assignmentRevision !== intent.input.assignmentRevision || turn.nodeIds.includes(item.nodeId)) fail('JOB_STALE', 'Correction target or assignment has changed');
|
|
85
|
+
const version = this.repo.get('tree_item_versions', versionKey(item.id, item.activeVersion));
|
|
86
|
+
const originalRefs = new Set(version.sourceRefs.map(canonicalJson));
|
|
87
|
+
const turns = this.repo.list('tree_turns', t => (t.experienceRefs ?? t.sourceRefs).some(r => originalRefs.has(canonicalJson(r))));
|
|
88
|
+
const retained = version.sourceRefs.filter(ref => {
|
|
89
|
+
const owners = turns.filter(t => (t.experienceRefs ?? t.sourceRefs).some(r => canonicalJson(r) === canonicalJson(ref)));
|
|
90
|
+
return !owners.length || owners.some(t => t.nodeIds.includes(item.nodeId) && t.status === 'settled');
|
|
91
|
+
});
|
|
92
|
+
const sources = unique(retained.map(ref => { const source = this.resolveSource(ref); if (!source) fail('SOURCE_CHANGED', 'A remaining source is unavailable'); return source; }));
|
|
93
|
+
if (!sources.length) fail('RECHECK_EVIDENCE_MISSING', 'No remaining evidence can support a model recheck');
|
|
94
|
+
const node = this.builder.node(item.nodeId), path = this.repo.ancestors(node.id).map(n => n.id);
|
|
95
|
+
const known = itemInput({ item, version, method: item.kind === 'method' ? this.repo.get('tree_methods', digest(['method', item.id, item.activeVersion])) : null });
|
|
96
|
+
// The old claim is the review target, not new evidence. Removed raw refs
|
|
97
|
+
// stay in the historical version and never enter this frozen request.
|
|
98
|
+
if (item.kind === 'method') { known.content = { ...known.content }; delete known.content.sourceRefs; }
|
|
99
|
+
const supplement = item.kind === 'supplement' ? this.repo.get('tree_supplements', digest(['supplement', item.id, item.activeVersion])) : null;
|
|
100
|
+
const input = { inputVersion: CONTRACT, role: 'verification', scope: { nodeIds: [node.id], ancestorPaths: [path], treeRevision: this.repo.meta().treeRevision },
|
|
101
|
+
sources, knownItems: [known], allowedIds: { nodeIds: path, subjectIds: [item.id], fileIds: [], opportunitySourceIds: [], clusters: [], nodeVersions: [], summaryVersions: [] }, changeReceipts: [],
|
|
102
|
+
sourceRecheck: { intentId: intent.id, itemId: item.id, itemVersion: item.activeVersion, removedSourceCount: version.sourceRefs.length - retained.length,
|
|
103
|
+
...(supplement ? { universalStopgap: supplement.universalStopgap, retainedTraitSourceIds: sources.filter(s => supplement.traitEvidence.some(ref => digest(ref) === digest(s.ref))).map(s => s.id) } : {}) },
|
|
104
|
+
assignmentRevision: turn.assignmentRevision, exposures: [], effects: [], trace: [], reviewNodeIds: [node.id], clusters: [],
|
|
105
|
+
window: { id: key, nodeId: node.id, reason: 'assignment_correction' } };
|
|
106
|
+
const revisionSet = { ['tree_items:' + item.id]: item.revision, ['tree_nodes:' + node.id]: node.revision };
|
|
107
|
+
for (const row of turns) revisionSet['tree_turns:' + row.id] = row.revision;
|
|
108
|
+
return this.jobs.enqueue({ role: 'verification', key, nodeIds: [node.id], input, revisionSet, config: this.config('verification', { turnId: turn.id, nodeIds: [node.id] }),
|
|
109
|
+
trigger: { kind: 'assignment_correction', producerRole: 'program', intentId: intent.id, turnId: turn.id, assignmentRevision: turn.assignmentRevision } });
|
|
110
|
+
}
|
|
111
|
+
applyResult(result, { job, requestId }) {
|
|
112
|
+
const output = validateOutput('verification', result, job.input);
|
|
113
|
+
return this.repo.transaction(() => {
|
|
114
|
+
this.jobs.assertCurrent(job);
|
|
115
|
+
const report = this.reports.recordReview(output, { job, requestId: digest([requestId, 'report']) });
|
|
116
|
+
if (job.input.sourceRecheck) {
|
|
117
|
+
const intentId = job.input.sourceRecheck.intentId;
|
|
118
|
+
this.repo.command('completeSourceRecheckReport', { requestId: digest([requestId, 'intent']), expected: {}, input: { intentId, jobId: job.id, reportId: report.reportId } },
|
|
119
|
+
{ kind: 'program', id: 'source-recheck' }, () => {
|
|
120
|
+
const intent = this.repo.get('tree_jobs', intentId);
|
|
121
|
+
if (!intent || intent.status !== 'queued') fail('JOB_STALE', 'Source recheck intent changed');
|
|
122
|
+
this.repo.save('tree_jobs', { ...intent, status: 'committed', ownedJobId: job.id, reportId: report.reportId }, intent.revision);
|
|
123
|
+
return { intentId, reportId: report.reportId };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { reportId: report.reportId, reportOnly: true };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** B3 owns summaries; B5 applies its already-validated relocation/proposals. */
|
|
132
|
+
export class ConsolidationReflection {
|
|
133
|
+
constructor({ repository, consolidation, resolveSource, writerRouter }) { Object.assign(this, { repo: repository, consolidation, resolveSource, writers: writerRouter }); }
|
|
134
|
+
applyResult(result, context) {
|
|
135
|
+
const { job } = context;
|
|
136
|
+
const output = validateOutput('consolidation', result, job.input);
|
|
137
|
+
if (job.trigger.reason === 'node_complete' && this.repo.get('tree_nodes', job.nodeIds[0]).status !== 'completed') fail('JOB_STALE', 'Node is no longer completed');
|
|
138
|
+
// Consolidation maintains the experience summary; proposed learning and
|
|
139
|
+
// promotion remain visible in the job output and are not applied.
|
|
140
|
+
const summary = this.consolidation.applyResult(output, context);
|
|
141
|
+
return { ...summary.result, moved: [], retrospect: { status: 'report_only' } };
|
|
142
|
+
}
|
|
143
|
+
}
|