@entrorove/skill 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CAPABILITY-MAP.md +6 -0
  2. package/CHANGELOG.md +5 -0
  3. package/SKILL.md +7 -1
  4. package/calctool-runtime-entry.mjs +2 -2
  5. package/cli.mjs +18 -1
  6. package/local-archguard-checkpoint.mjs +1 -1
  7. package/local-archguard-snapshot.mjs +12 -12
  8. package/local-calctool-cli.mjs +3 -1
  9. package/local-calctool-runtime.mjs +1 -1
  10. package/local-calctool.mjs +1 -1
  11. package/local-chain-bindings.mjs +93 -0
  12. package/local-chain-broker.mjs +61 -0
  13. package/local-chain-contracts.mjs +21 -0
  14. package/local-chain-controller.mjs +321 -0
  15. package/local-chain-human.mjs +48 -0
  16. package/local-chain-lock.mjs +54 -0
  17. package/local-chain-operations.mjs +7 -0
  18. package/local-chain-plan.mjs +232 -0
  19. package/local-chain-release-labels.mjs +12 -0
  20. package/local-chain-results.mjs +75 -0
  21. package/local-cli.mjs +23 -15
  22. package/local-contract-types.mjs +1 -1
  23. package/local-contracts.mjs +2 -0
  24. package/local-error.mjs +23 -0
  25. package/local-execution.mjs +1 -1
  26. package/local-final-gate16-bundle.mjs +2 -2
  27. package/local-formula-ast.mjs +23 -14
  28. package/local-formula-engine.mjs +17 -3
  29. package/local-formula.mjs +1 -1
  30. package/local-invocations.mjs +35 -0
  31. package/local-ledger.mjs +13 -2
  32. package/local-mutation-authority.mjs +13 -6
  33. package/local-mutation-binding.mjs +5 -2
  34. package/local-mutation-files.mjs +29 -4
  35. package/local-task-handoff-observation.mjs +4 -1
  36. package/manifest.json +1 -1
  37. package/package.json +12 -1
  38. package/release-state.json +38 -28
  39. package/release.mjs +5 -1
  40. package/releases.json +6 -0
  41. package/runtime/calctool-runtime-v7.0.43.mjs +40 -17
@@ -0,0 +1,321 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { withChainLock } from './local-chain-lock.mjs';
5
+ import { captureChainCloud, checkChainCloud, checkChainSkillDispatch, inspectChainSkillResponse } from './local-chain-broker.mjs';
6
+ import { originalChainInteraction, readChainTerminalAnswer, inspectChainHumanResponse } from './local-chain-human.mjs';
7
+ import { validateArchivedChainResults, revalidateArchivedChainSkill, revalidateArchivedChainCommand } from './local-chain-results.mjs';
8
+ import { buildInvocation, createTaskMessageClient } from './remote-client.mjs';
9
+ import { runManagedWorkflowLocalStep } from './workflow-local-client.mjs';
10
+ import { observeWorkflowLocalStep } from './workflow-local-runner.mjs';
11
+ import { bindLocalChainStepInput, localChainPlanDigest, validateLocalChainPlan } from './local-chain-plan.mjs';
12
+ import { captureChainPackage, checkChainPackage, validateChainBindings, chainCanonical as canonical,
13
+ chainControlFile, chainExact as exact, chainFail as fail, chainSha as sha } from './local-chain-bindings.mjs';
14
+
15
+ export const LOCAL_CHAIN_CONTROLLER_SCHEMA = 'entrorove.local-chain-controller/1.0';
16
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
17
+ const SHA = /^[0-9a-f]{64}$/;
18
+ const STATES = ['pending', 'running', 'waiting', 'reported', 'verified', 'uncertain', 'blocked', 'failed'];
19
+ const MAX_BYTES = 8 * 1024 * 1024;
20
+ function safeId(value) { if (typeof value !== 'string' || !ID.test(value)) fail('ENTROROVE_CHAIN_ID', 'chain identity must be explicit and safe'); }
21
+ async function directory(value) {
22
+ if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_CHAIN_DIRECTORY', 'chain home and repository must be explicit absolute directories');
23
+ const stat = await fs.lstat(value);
24
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_CHAIN_DIRECTORY', 'chain directories must be real directories');
25
+ return fs.realpath(value);
26
+ }
27
+ async function location(input, create = false) {
28
+ const repositoryRoot = await directory(input.repositoryRoot), home = await directory(input.home);
29
+ safeId(input.chainId ?? input.plan?.chainId);
30
+ let target = home;
31
+ for (const name of ['.entrorove', 'chain-controller', sha(repositoryRoot).slice(0, 32), input.chainId ?? input.plan.chainId]) {
32
+ target = path.join(target, name);
33
+ if (create) await fs.mkdir(target, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error; });
34
+ const stat = await fs.lstat(target);
35
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o700
36
+ || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) fail('ENTROROVE_CHAIN_STORAGE', 'chain state directories must be owned, private and real');
37
+ }
38
+ return { repositoryRoot, home, file: path.join(target, 'state.json') };
39
+ }
40
+ async function atomic(file, value) {
41
+ const bytes = Buffer.from(canonical(value) + '\n');
42
+ if (bytes.length > MAX_BYTES) fail('ENTROROVE_CHAIN_JOURNAL_LIMIT', 'chain journal exceeds its control byte bound');
43
+ const temporary = `${file}.${randomUUID()}.tmp`;
44
+ const descriptor = await fs.open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
45
+ try { await descriptor.writeFile(bytes); await descriptor.sync(); }
46
+ finally { await descriptor.close(); }
47
+ try {
48
+ await fs.rename(temporary, file);
49
+ const directory = await fs.open(path.dirname(file), constants.O_RDONLY);
50
+ try { await directory.sync(); } finally { await directory.close(); }
51
+ }
52
+ finally { await fs.rm(temporary, { force: true }); }
53
+ }
54
+ function reduce(records) {
55
+ let state = null, head = null;
56
+ for (const [index, record] of records.entries()) {
57
+ exact(record, ['sequence', 'event', 'at', 'stepId', 'data', 'previousDigest', 'digest']);
58
+ const { digest, ...core } = record;
59
+ if (record.sequence !== index + 1 || record.previousDigest !== head || !SHA.test(digest) || sha(canonical(core)) !== digest) {
60
+ fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain journal hash lineage is invalid');
61
+ }
62
+ if (index === 0) {
63
+ if (record.event !== 'init' || record.stepId !== null) fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain journal must start with its original initialization');
64
+ state = structuredClone(record.data);
65
+ if (!state || typeof state !== 'object' || !Array.isArray(state.steps) || !state.identity) {
66
+ fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain initialization controls are invalid');
67
+ }
68
+ } else {
69
+ if (record.event !== 'step') fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain journal contains an unknown event');
70
+ const step = state.steps.find(value => value.stepId === record.stepId);
71
+ if (!step || !record.data || !STATES.includes(record.data.status)) fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain journal changed its original step or status');
72
+ if (record.data.phase === 'answer' && step.output?.interaction && !step.originalInteraction) {
73
+ step.originalInteraction = structuredClone(step.output);
74
+ }
75
+ Object.assign(step, structuredClone(record.data));
76
+ }
77
+ head = digest;
78
+ }
79
+ if (!state || state.steps.some(step => !step || typeof step !== 'object' || !STATES.includes(step.status))
80
+ || localChainPlanDigest(state.plan) !== state.planDigest || state.steps.length !== state.plan.steps.length
81
+ || state.steps.some((step, index) => step.stepId !== state.plan.steps[index].stepId)) fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain journal lost its immutable original plan');
82
+ return state;
83
+ }
84
+ async function load(ctx) {
85
+ const { bytes, stat } = await chainControlFile(ctx.file);
86
+ if ((stat.mode & 0o777) !== 0o600) fail('ENTROROVE_CHAIN_STORAGE', 'chain journal must remain private');
87
+ let journal; try { journal = JSON.parse(bytes.toString('utf8')); } catch { fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain journal JSON is invalid'); }
88
+ exact(journal, ['schemaVersion', 'records']);
89
+ if (journal.schemaVersion !== LOCAL_CHAIN_CONTROLLER_SCHEMA || !Array.isArray(journal.records) || !journal.records.length) fail('ENTROROVE_CHAIN_JOURNAL_INVALID', 'chain journal schema is invalid');
90
+ const state = reduce(journal.records);
91
+ if (state.identity.home !== ctx.home || state.identity.repositoryRoot !== ctx.repositoryRoot) fail('ENTROROVE_CHAIN_BINDING', 'chain journal belongs to another original home or repository');
92
+ await checkChainPackage(state);
93
+ await validateArchivedChainResults(state);
94
+ return { ctx, journal, state };
95
+ }
96
+ async function append(loaded, stepId, data, event = 'step') {
97
+ const records = loaded.journal.records;
98
+ const core = { sequence: records.length + 1, event, at: new Date().toISOString(), stepId, data,
99
+ previousDigest: records.at(-1)?.digest ?? null };
100
+ const journal = { schemaVersion: LOCAL_CHAIN_CONTROLLER_SCHEMA, records: [...records, { ...core, digest: sha(canonical(core)) }] };
101
+ await atomic(loaded.ctx.file, journal); loaded.journal = journal; loaded.state = reduce(journal.records);
102
+ }
103
+ function view(loaded) {
104
+ const steps = loaded.state.steps;
105
+ const status = steps.find(step => ['failed', 'blocked', 'uncertain', 'waiting', 'running', 'reported', 'pending'].includes(step.status))?.status ?? 'verified';
106
+ return { schemaVersion: LOCAL_CHAIN_CONTROLLER_SCHEMA, chainId: loaded.state.plan.chainId, status,
107
+ flowId: loaded.state.identity.flowId, steps: structuredClone(steps), sequence: loaded.journal.records.length,
108
+ head: loaded.journal.records.at(-1).digest, authority: 'original-workflow-step-results', taskCompletionVerified: false };
109
+ }
110
+ async function dependenciesFor(home, supplied) { return supplied ?? (await import('./cli.mjs')).remoteDependencies(home); }
111
+ function checkCloudTask(output, identity) {
112
+ if (!output?.task || output.task.taskId !== identity.taskId || output.task.ownerId !== identity.ownerId
113
+ || output.task.flowId !== identity.flowId || output.flowId !== identity.flowId || output.continuity?.handoffPaused
114
+ || output.continuity?.activeTakeover || !['accepted', 'active'].includes(output.task.status)) {
115
+ fail('ENTROROVE_CHAIN_CLOUD_BINDING', 'original cloud task cannot authorize this local chain');
116
+ }
117
+ }
118
+ function originalResponse(output, requestId) {
119
+ if (!output || output.requestId !== requestId || output.schemaVersion !== 'entrorove.skill.response/1.0') {
120
+ fail('ENTROROVE_CHAIN_RESPONSE_BINDING', 'cloud result must retain its original canonical request ID');
121
+ }
122
+ return output;
123
+ }
124
+ async function cloudRequest(loaded, step, operation, input, client, phase) {
125
+ const expected = buildInvocation(operation, input), existing = step.request;
126
+ if (existing && (existing.requestId !== expected.requestId || existing.operation !== operation
127
+ || canonical(existing.input) !== canonical(input))) fail('ENTROROVE_CHAIN_REQUEST_BINDING', 'pending cloud request cannot be replaced');
128
+ const request = existing ?? { requestId: expected.requestId, operation, input };
129
+ if (!existing) await append(loaded, step.stepId, { ...step, request, phase, status: step.status });
130
+ try {
131
+ // A saved request can have been sent before an abrupt host exit. Recovery
132
+ // observes that exact durable result; it never issues another POST.
133
+ return originalResponse(await (existing ? client.status(request.requestId) : client.invoke(operation, input)), request.requestId);
134
+ } catch (error) {
135
+ await append(loaded, step.stepId, { ...loaded.state.steps.find(value => value.stepId === step.stepId),
136
+ status: 'uncertain', error: { code: error.code ?? 'ENTROROVE_CHAIN_CLOUD_UNKNOWN', requestId: request.requestId } });
137
+ return null;
138
+ }
139
+ }
140
+
141
+ export async function initLocalChain(input, suppliedDependencies) {
142
+ exact(input, ['repositoryRoot', 'home', 'flowId', 'ownerId', 'taskId', 'plan', 'commandBindings']);
143
+ for (const field of ['flowId', 'ownerId', 'taskId']) safeId(input[field]);
144
+ const plan = validateLocalChainPlan(input.plan), ctx = await location({ ...input, plan }, true);
145
+ return withChainLock(ctx.file, async () => {
146
+ try { await fs.lstat(ctx.file); fail('ENTROROVE_CHAIN_EXISTS', 'the original chain already exists; resume it without reinitializing'); }
147
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
148
+ const identity = { repositoryRoot: ctx.repositoryRoot, home: ctx.home, flowId: input.flowId, ownerId: input.ownerId, taskId: input.taskId };
149
+ const bindings = await validateChainBindings(plan, input.commandBindings, identity), packagePin = await captureChainPackage(plan);
150
+ const configured = await dependenciesFor(ctx.home, suppliedDependencies), client = createTaskMessageClient(configured);
151
+ const resumed = await client.invoke('task-resume', { flowId: identity.flowId }); checkCloudTask(resumed, identity);
152
+ const cloud = await captureChainCloud(plan, client);
153
+ const state = { identity, plan, planDigest: localChainPlanDigest(plan), commandBindings: bindings, package: packagePin, cloud,
154
+ apiOrigin: new URL(configured.apiOrigin()).origin,
155
+ steps: plan.steps.map(step => ({ stepId: step.stepId, status: 'pending', phase: 'execute', request: null, localResult: null, output: null, error: null })) };
156
+ const loaded = { ctx, journal: { schemaVersion: LOCAL_CHAIN_CONTROLLER_SCHEMA, records: [] }, state: null };
157
+ await append(loaded, null, state, 'init'); return view(loaded);
158
+ });
159
+ }
160
+
161
+ async function reconcileSkill(loaded, original, input, client) {
162
+ let step = loaded.state.steps.find(value => value.stepId === original.stepId);
163
+ if (step.status === 'waiting') return false;
164
+ if (!step.request) {
165
+ const resumed = await client.invoke('task-resume', { flowId: loaded.state.identity.flowId });
166
+ checkCloudTask(resumed, loaded.state.identity); checkChainSkillDispatch(original, loaded.state, resumed, input);
167
+ await append(loaded, step.stepId, { ...step, status: 'running' });
168
+ step = loaded.state.steps.find(value => value.stepId === original.stepId);
169
+ }
170
+ const invocation = { flowId: loaded.state.identity.flowId, operation: original.operation, payload: input };
171
+ const response = await cloudRequest(loaded, step, 'continue', invocation, client, 'skill');
172
+ if (!response) return false;
173
+ const observed = inspectChainSkillResponse(original, loaded.state, response);
174
+ await append(loaded, step.stepId, { ...step, status: observed.eligible ? 'verified' : observed.state,
175
+ phase: observed.eligible ? 'done' : 'skill', output: response.result, request: loaded.state.steps.find(value => value.stepId === step.stepId).request,
176
+ response, error: observed.eligible ? null : { code: observed.code } });
177
+ return observed.eligible;
178
+ }
179
+
180
+ async function reconcileAnswer(loaded, original, client) {
181
+ const step = loaded.state.steps.find(value => value.stepId === original.stepId);
182
+ const response = await cloudRequest(loaded, step, 'continue', step.request.input, client, 'answer');
183
+ if (!response) return false;
184
+ const observed = inspectChainHumanResponse(original, loaded.state, step, response);
185
+ await append(loaded, step.stepId, { ...step, status: observed.eligible ? 'verified' : observed.state,
186
+ phase: observed.eligible ? 'done' : 'answer', output: { ...response.result, answer: response.result.auditEntry.answer },
187
+ response, error: observed.eligible ? null : { code: observed.code } });
188
+ return observed.eligible;
189
+ }
190
+
191
+ export async function statusLocalChain(input, suppliedDependencies) {
192
+ exact(input, ['repositoryRoot', 'home', 'chainId']); const ctx = await location(input);
193
+ const loaded = await load(ctx), verified = loaded.state.plan.steps.filter(original =>
194
+ loaded.state.steps.find(step => step.stepId === original.stepId)?.status === 'verified');
195
+ if (verified.length) {
196
+ const configured = await dependenciesFor(ctx.home, suppliedDependencies);
197
+ if (new URL(configured.apiOrigin()).origin !== loaded.state.apiOrigin) fail('ENTROROVE_CHAIN_CLOUD_BINDING', 'status cannot switch its original cloud origin');
198
+ const client = createTaskMessageClient(configured);
199
+ for (const original of verified) await (original.kind === 'command' ? revalidateArchivedChainCommand : revalidateArchivedChainSkill)(
200
+ loaded.state, original, loaded.state.steps.find(step => step.stepId === original.stepId), client);
201
+ }
202
+ return view(loaded);
203
+ }
204
+ async function localCommand(loaded, step, binding, configured) {
205
+ let result;
206
+ try {
207
+ if (step.status !== 'pending') {
208
+ result = await observeWorkflowLocalStep(binding.workflowInput);
209
+ if (!result) { await append(loaded, step.stepId, { ...step, status: 'uncertain', error: { code: 'ENTROROVE_CHAIN_PROCESS_UNKNOWN' } }); return false; }
210
+ } else {
211
+ await append(loaded, step.stepId, { ...step, status: 'running' });
212
+ result = await runManagedWorkflowLocalStep(binding.workflowInput, configured);
213
+ }
214
+ } catch (error) {
215
+ const uncertain = error.outcomeUnknown || ['ENTROROVE_WORKFLOW_RECOVERY_REQUIRED', 'ENTROROVE_WORKFLOW_BUSY'].includes(error.code);
216
+ await append(loaded, step.stepId, { ...loaded.state.steps.find(value => value.stepId === step.stepId),
217
+ status: uncertain ? 'uncertain' : 'failed', error: { code: error.code ?? 'ENTROROVE_CHAIN_EXECUTION_REJECTED' } }); return false;
218
+ }
219
+ if (!result.receipt || !result.evidence?.length || !Number.isInteger(result.process?.exitCode)) {
220
+ await append(loaded, step.stepId, { ...step, status: 'uncertain', localResult: result, error: { code: 'ENTROROVE_CHAIN_PROCESS_UNKNOWN' } }); return false;
221
+ }
222
+ await append(loaded, step.stepId, { ...step, status: 'reported', phase: 'report', localResult: result, request: null, error: null }); return true;
223
+ }
224
+ function checkReported(output, loaded, step, binding, verified) {
225
+ const value = output.result, evidence = step.localResult.evidence[0], lease = binding.workflowInput.lease;
226
+ if (!['succeeded', 'reported'].includes(output.status) || !value || value.flowId !== loaded.state.identity.flowId
227
+ || value.stepId !== step.stepId || value.generation !== lease.generation || value.lease?.leaseId !== lease.leaseId
228
+ || value.evidenceDigest !== sha(canonical(evidence)) || value.status !== (verified ? 'verified' : step.localResult.status)) {
229
+ fail('ENTROROVE_CHAIN_RESULT_BINDING', 'cloud execution result differs from its original process, lease or evidence');
230
+ }
231
+ if (verified && (value.verification?.summary?.passed !== 1 || value.verification?.summary?.failed !== 0
232
+ || value.verification?.findings?.length !== 0)) fail('ENTROROVE_CHAIN_VERIFICATION', 'original independent verification did not pass the process receipt');
233
+ return value;
234
+ }
235
+ async function reconcileCommand(loaded, stepId, configured, client) {
236
+ let step = loaded.state.steps.find(value => value.stepId === stepId);
237
+ const binding = loaded.state.commandBindings.find(value => value.stepId === stepId);
238
+ if (step.phase === 'execute') {
239
+ if (!await localCommand(loaded, step, binding, configured)) return false;
240
+ step = loaded.state.steps.find(value => value.stepId === stepId);
241
+ }
242
+ if (step.phase === 'report') {
243
+ const input = { flowId: loaded.state.identity.flowId, operation: 'execution-report', payload: { stepId,
244
+ leaseId: binding.workflowInput.lease.leaseId, generation: binding.workflowInput.lease.generation, evidence: step.localResult.evidence[0] } };
245
+ const output = await cloudRequest(loaded, step, 'continue', input, client, 'report'); if (!output) return false;
246
+ const reported = checkReported(output, loaded, step, binding, false);
247
+ if (reported.status === 'failed') { await append(loaded, stepId, { ...step, status: 'failed', output: reported, request: null }); return false; }
248
+ await append(loaded, stepId, { ...step, status: 'reported', phase: 'verify', output: reported, request: null, error: null });
249
+ step = loaded.state.steps.find(value => value.stepId === stepId);
250
+ }
251
+ if (step.phase === 'verify') {
252
+ const input = { flowId: loaded.state.identity.flowId, operation: 'execution-verify', payload: { stepId } };
253
+ const output = await cloudRequest(loaded, step, 'continue', input, client, 'verify'); if (!output) return false;
254
+ const verified = checkReported(output, loaded, step, binding, true);
255
+ await append(loaded, stepId, { ...step, status: 'verified', phase: 'done', output: verified, request: null, error: null });
256
+ }
257
+ return true;
258
+ }
259
+
260
+ export async function resumeLocalChain(input, suppliedDependencies) {
261
+ exact(input, ['repositoryRoot', 'home', 'chainId']); const ctx = await location(input);
262
+ return withChainLock(ctx.file, async () => {
263
+ const loaded = await load(ctx), configured = await dependenciesFor(ctx.home, suppliedDependencies);
264
+ if (new URL(configured.apiOrigin()).origin !== loaded.state.apiOrigin) fail('ENTROROVE_CHAIN_CLOUD_BINDING', 'chain recovery cannot switch its original cloud origin');
265
+ const client = createTaskMessageClient(configured);
266
+ await checkChainCloud(loaded.state, client);
267
+ for (const original of loaded.state.plan.steps) {
268
+ const step = loaded.state.steps.find(value => value.stepId === original.stepId);
269
+ if (step.status === 'verified') {
270
+ await (original.kind === 'command' ? revalidateArchivedChainCommand : revalidateArchivedChainSkill)(loaded.state, original, step, client);
271
+ continue;
272
+ }
273
+ if (['failed', 'blocked'].includes(step.status)) return view(loaded);
274
+ const boundInput = bindLocalChainStepInput(loaded.state.plan, original.stepId, loaded.state.steps.filter(value => value.status === 'verified')
275
+ .map(value => ({ stepId: value.stepId, status: 'succeeded', output: value.output })));
276
+ if (original.kind === 'coordinator') {
277
+ await append(loaded, original.stepId, { ...step, status: 'blocked', error: { code: 'ENTROROVE_CHAIN_ADAPTER_PENDING', kind: original.kind } }); return view(loaded);
278
+ }
279
+ try {
280
+ const complete = original.kind === 'command' ? await reconcileCommand(loaded, original.stepId, configured, client)
281
+ : step.phase === 'answer' ? await reconcileAnswer(loaded, original, client) : await reconcileSkill(loaded, original, boundInput, client);
282
+ if (!complete) return view(loaded);
283
+ }
284
+ catch (error) {
285
+ const current = loaded.state.steps.find(value => value.stepId === original.stepId);
286
+ await append(loaded, original.stepId, { ...current, status: 'blocked', error: { code: error.code ?? 'ENTROROVE_CHAIN_RECONCILE_REJECTED' } }); return view(loaded);
287
+ }
288
+ }
289
+ return view(loaded);
290
+ });
291
+ }
292
+
293
+ export async function answerLocalChain(input, suppliedDependencies) {
294
+ exact(input, ['repositoryRoot', 'home', 'chainId', 'actorId']); safeId(input.actorId);
295
+ const ctx = await location(input);
296
+ return withChainLock(ctx.file, async () => {
297
+ const loaded = await load(ctx), configured = await dependenciesFor(ctx.home, suppliedDependencies);
298
+ if (new URL(configured.apiOrigin()).origin !== loaded.state.apiOrigin) fail('ENTROROVE_CHAIN_CLOUD_BINDING', 'answer cannot switch its original cloud origin');
299
+ const client = createTaskMessageClient(configured); await checkChainCloud(loaded.state, client);
300
+ const step = loaded.state.steps.find(value => value.status !== 'verified');
301
+ const original = loaded.state.plan.steps.find(value => value.stepId === step?.stepId);
302
+ if (original?.kind !== 'skill' || original.skillId !== 'confirm-protocol' || original.operation !== 'interaction-request') {
303
+ fail('ENTROROVE_CHAIN_DECISION_REQUIRED', 'only an original pending confirmation can accept a terminal answer');
304
+ }
305
+ if (step.phase === 'answer' && step.request) { await reconcileAnswer(loaded, original, client); return view(loaded); }
306
+ if (step.status !== 'waiting') fail('ENTROROVE_CHAIN_DECISION_REQUIRED', 'the original confirmation must be genuinely waiting');
307
+ const resumed = await client.invoke('task-resume', { flowId: loaded.state.identity.flowId }); checkCloudTask(resumed, loaded.state.identity);
308
+ const question = originalChainInteraction(step, loaded.state, resumed), payload = await readChainTerminalAnswer(question);
309
+ const invocation = { flowId: loaded.state.identity.flowId, operation: 'interaction-answer', payload };
310
+ await append(loaded, step.stepId, { ...step, phase: 'answer', status: 'running', request: null, humanActorLabel: input.actorId });
311
+ const current = loaded.state.steps.find(value => value.stepId === step.stepId);
312
+ const response = await cloudRequest(loaded, current, 'continue', invocation, client, 'answer');
313
+ if (response) {
314
+ const saved = loaded.state.steps.find(value => value.stepId === step.stepId), observed = inspectChainHumanResponse(original, loaded.state, saved, response);
315
+ await append(loaded, step.stepId, { ...saved, status: observed.eligible ? 'verified' : observed.state,
316
+ phase: observed.eligible ? 'done' : 'answer', output: { ...response.result, answer: response.result.auditEntry.answer }, response,
317
+ error: observed.eligible ? null : { code: observed.code } });
318
+ }
319
+ return view(loaded);
320
+ });
321
+ }
@@ -0,0 +1,48 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { chainCanonical as canonical, chainFail as fail, chainSha as sha } from './local-chain-bindings.mjs';
3
+ import { inspectLocalChainResult } from './local-chain-plan.mjs';
4
+
5
+ export function originalChainInteraction(step, state, resumed) {
6
+ const value = step.output, current = resumed.result;
7
+ if (!value?.interaction || !value.interactionId || value.interactionDigest !== sha(canonical(value.interaction))
8
+ || current?.interactionId !== value.interactionId || current.interactionDigest !== value.interactionDigest
9
+ || canonical(current.interaction) !== canonical(value.interaction)
10
+ || resumed.nextStep?.operation !== 'continue' || resumed.nextStep.input?.flowId !== state.identity.flowId
11
+ || resumed.nextStep.input.operation !== 'interaction-answer') {
12
+ fail('ENTROROVE_CHAIN_INTERACTION_BINDING', 'answer must address the exact original live interaction');
13
+ }
14
+ const interaction = value.interaction;
15
+ if (interaction.callback?.operation !== 'interaction-answer' || canonical(interaction.callback.payload) !== '{}') {
16
+ fail('ENTROROVE_CHAIN_CALLBACK_UNSUPPORTED', 'this fixed host accepts only the original workflow interaction-answer callback');
17
+ }
18
+ return value;
19
+ }
20
+ export async function readChainTerminalAnswer(value) {
21
+ if (!process.stdin.isTTY || !process.stdout.isTTY) fail('ENTROROVE_CHAIN_TTY_REQUIRED', 'a real interactive terminal is required; redirected answers are rejected');
22
+ const interaction = value.interaction, ids = interaction.options?.map(option => option.id);
23
+ if (!['confirm', 'choice', 'multi', 'input'].includes(interaction.type) || !Array.isArray(ids)
24
+ || new Set(ids).size !== ids.length || (interaction.type !== 'input' && ids.length < 2)) {
25
+ fail('ENTROROVE_CHAIN_INTERACTION_BINDING', 'original interaction option contract is invalid');
26
+ }
27
+ process.stdout.write(`${interaction.question}\n${interaction.riskDescription ?? ''}\n`);
28
+ for (const option of interaction.options) process.stdout.write(`${option.id}: ${option.label}\n`);
29
+ const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
30
+ let answer; try { answer = (await terminal.question('Answer with the exact option ID: ')).trim(); } finally { terminal.close(); }
31
+ if (interaction.type === 'multi') answer = answer.split(',').map(value => value.trim());
32
+ if (interaction.type === 'input' ? !answer : Array.isArray(answer)
33
+ ? !answer.length || new Set(answer).size !== answer.length || answer.some(value => !ids.includes(value)) : !ids.includes(answer)) {
34
+ fail('ENTROROVE_CHAIN_ANSWER_INVALID', 'terminal answer must match explicit original option IDs');
35
+ }
36
+ return { interactionId: value.interactionId, interactionDigest: value.interactionDigest, answer, remembered: false };
37
+ }
38
+ export function inspectChainHumanResponse(original, state, step, response) {
39
+ const native = response.result, audit = native?.auditEntry, question = step.output.interaction;
40
+ if (response.flowId !== state.identity.flowId || native?.interactionId !== step.output.interactionId
41
+ || native.interactionDigest !== step.output.interactionDigest || audit?.schemaVersion !== 'confirm.audit-entry/1.0'
42
+ || audit.actorId !== state.identity.ownerId || audit.requestId !== question.requestId || audit.question !== question.question
43
+ || canonical(audit.answer) !== canonical(step.request.input.payload.answer) || audit.risk !== question.risk
44
+ || !audit.auditId || !Number.isFinite(Date.parse(audit.answeredAt))) {
45
+ fail('ENTROROVE_CHAIN_ANSWER_BINDING', 'answer has no matching original server-persisted audit and callback result');
46
+ }
47
+ return inspectLocalChainResult(original, { ...response, result: { ...native, answer: audit.answer } });
48
+ }
@@ -0,0 +1,54 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { chainControlFile, chainFail } from './local-chain-bindings.mjs';
4
+
5
+ function alive(pid) {
6
+ if (!Number.isSafeInteger(pid) || pid <= 0) chainFail('ENTROROVE_CHAIN_LOCK_INVALID', 'chain lock PID is invalid');
7
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
8
+ }
9
+ async function owner(lock) {
10
+ const stat = await fs.lstat(lock);
11
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o700
12
+ || (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
13
+ chainFail('ENTROROVE_CHAIN_LOCK_INVALID', 'chain lock must be owned, private and real');
14
+ }
15
+ let value; try { value = JSON.parse((await chainControlFile(`${lock}/owner.json`, 1024)).bytes.toString()); }
16
+ catch { chainFail('ENTROROVE_CHAIN_LOCK_INVALID', 'chain lock owner is invalid'); }
17
+ if (!value || typeof value.token !== 'string' || !Number.isSafeInteger(value.pid)) {
18
+ chainFail('ENTROROVE_CHAIN_LOCK_INVALID', 'chain lock owner is invalid');
19
+ }
20
+ return { stat, value };
21
+ }
22
+ async function recover(lock) {
23
+ let original; try { original = await owner(lock); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
24
+ if (alive(original.value.pid)) return;
25
+ const removed = `${lock}.dead-${randomUUID()}`;
26
+ try { await fs.rename(lock, removed); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
27
+ const moved = await owner(removed);
28
+ if (moved.stat.ino !== original.stat.ino || moved.stat.dev !== original.stat.dev
29
+ || moved.value.token !== original.value.token) chainFail('ENTROROVE_CHAIN_LOCK_CHANGED', 'chain lock changed during recovery');
30
+ await fs.rm(removed, { recursive: true });
31
+ }
32
+ export async function withChainLock(file, action) {
33
+ const lock = `${file}.lock`, staging = `${lock}.private-${randomUUID()}`, token = randomUUID();
34
+ await fs.mkdir(staging, { mode: 0o700 });
35
+ const handle = await fs.open(`${staging}/owner.json`, 'wx', 0o600);
36
+ try { await handle.writeFile(JSON.stringify({ pid: process.pid, token })); await handle.sync(); } finally { await handle.close(); }
37
+ const deadline = Date.now() + 10_000;
38
+ try {
39
+ while (true) {
40
+ try { await fs.rename(staging, lock); break; }
41
+ catch (error) {
42
+ if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
43
+ await recover(lock);
44
+ if (Date.now() >= deadline) chainFail('ENTROROVE_CHAIN_BUSY', 'the original chain is running in another live host');
45
+ await new Promise(resolve => setTimeout(resolve, 25));
46
+ }
47
+ }
48
+ try { return await action(); }
49
+ finally {
50
+ if ((await owner(lock)).value.token !== token) chainFail('ENTROROVE_CHAIN_LOCK_CHANGED', 'chain lock ownership changed');
51
+ await fs.rm(lock, { recursive: true });
52
+ }
53
+ } finally { await fs.rm(staging, { recursive: true, force: true }); }
54
+ }
@@ -0,0 +1,7 @@
1
+ import { initLocalChain, resumeLocalChain, statusLocalChain, answerLocalChain, LOCAL_CHAIN_CONTROLLER_SCHEMA } from './local-chain-controller.mjs';
2
+ export const LOCAL_CHAIN_OPERATIONS = Object.freeze({ 'chain-init': initLocalChain, 'chain-resume': resumeLocalChain,
3
+ 'chain-status': statusLocalChain, 'chain-answer': answerLocalChain });
4
+ export const LOCAL_CHAIN_CAPABILITIES = Object.freeze({ schemaVersion: LOCAL_CHAIN_CONTROLLER_SCHEMA,
5
+ operations: Object.keys(LOCAL_CHAIN_OPERATIONS), authority: 'original-workflow-step-results',
6
+ taskCompletionVerified: false, coordinatorStatus: 'adapter-pending', answerRequiresInteractiveTerminal: true,
7
+ legacyLedger: { operations: ['chain-ledger-init', 'chain-ledger-status', 'chain-ledger-query'], claimsExecution: false } });