@entrorove/skill 0.1.73 → 0.1.78
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/CAPABILITY-MAP.md +5 -0
- package/CHANGELOG.md +37 -0
- package/DESIGN-GATES.md +5 -9
- package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
- package/IDE-EXECUTION.md +21 -4
- package/REUSE-DISCIPLINE.md +3 -3
- package/SKILL.md +14 -4
- package/cli.mjs +8 -4
- package/cloud-mutation-authority.mjs +8 -2
- package/cloud-task-client.mjs +10 -0
- package/cloud-watch-client.mjs +8 -2
- package/design-browser-checks.mjs +18 -5
- package/design-browser-pages.mjs +96 -0
- package/design-browser-state.mjs +1 -0
- package/design-browser.mjs +36 -40
- package/design-contracts.mjs +4 -1
- package/design-declaration.mjs +3 -0
- package/design-frameworks.mjs +2 -1
- package/design-gate.mjs +3 -0
- package/design-operations.mjs +4 -1
- package/design-policy.mjs +12 -4
- package/design-release.mjs +6 -0
- package/design-research-network.mjs +82 -0
- package/design-research.mjs +8 -25
- package/design-runner.mjs +3 -0
- package/design-stage-contracts.mjs +12 -0
- package/design-stage.mjs +78 -0
- package/design-storage.mjs +63 -0
- package/fs-request-trace.mjs +67 -0
- package/local-contracts.mjs +1 -1
- package/local-discipline-repository.mjs +4 -2
- package/local-invocations.mjs +6 -0
- package/local-mutation-files.mjs +13 -5
- package/local-probe.mjs +8 -4
- package/local-recovery-budget.mjs +31 -0
- package/local-scope-repository.mjs +18 -3
- package/local-scope-signature.mjs +2 -2
- package/local-source-execution.mjs +47 -0
- package/manifest.json +1 -1
- package/mutation-challenge-refresh.mjs +3 -0
- package/mutation-client.mjs +17 -5
- package/native-client.mjs +4 -1
- package/package.json +14 -1
- package/release-state.json +63 -50
- package/release.mjs +6 -1
- package/releases.json +30 -0
- package/remote-capability-cache.mjs +20 -0
- package/remote-client.mjs +24 -4
- package/scope-amendment-client.mjs +62 -6
- package/scope-amendment-observer.mjs +9 -3
- package/scope-amendment-remote.mjs +20 -6
- package/scope-amendment-retirement.mjs +4 -2
- package/scope-amendment-state.mjs +9 -5
- package/scope-context-client.mjs +7 -0
- package/scope-context-journal.mjs +8 -2
- package/scope-context-remote.mjs +16 -5
- package/scope-evidence-remote.mjs +16 -7
- package/scope-runner-client.mjs +34 -7
- package/scope-runner-journal.mjs +82 -5
- package/scope-write-observation.d.mts +4 -0
- package/scope-write-observation.mjs +43 -0
- package/service-prerequisite.mjs +87 -0
- package/task-entry-client.mjs +3 -0
- package/workflow-observation-client.mjs +2 -0
- package/workflow-observation-remote.mjs +13 -5
package/design-stage.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { designFail } from './design-policy.mjs';
|
|
2
|
+
import { browserStepHandoffs } from './design-storage.mjs';
|
|
3
|
+
|
|
4
|
+
export function validateDesignStage(policy) {
|
|
5
|
+
const stage = policy.rules.stage;
|
|
6
|
+
if (!stage) return;
|
|
7
|
+
const active = new Set(stage.pageIds), passive = new Set(stage.readOnlyPages.map(item => item.pageId));
|
|
8
|
+
if (active.size !== stage.pageIds.length || passive.size !== stage.readOnlyPages.length
|
|
9
|
+
|| stage.requirementIds.some(id => !policy.site.requirementIds.includes(id))
|
|
10
|
+
|| policy.site.pages.some(page => Number(active.has(page.id)) + Number(passive.has(page.id)) !== 1)
|
|
11
|
+
|| [...active, ...passive].some(id => !policy.site.pages.some(page => page.id === id))) {
|
|
12
|
+
designFail('STAGE', 'A stage retains the whole site and partitions every original page into executable or source-bound read-only context');
|
|
13
|
+
}
|
|
14
|
+
for (const id of stage.edgeIds) {
|
|
15
|
+
const edge = policy.site.edges.find(item => item.id === id);
|
|
16
|
+
if (!edge || !active.has(edge.from) || !active.has(edge.to)) designFail('STAGE', 'Every executable stage edge must retain its original endpoints');
|
|
17
|
+
}
|
|
18
|
+
const sources = new Map();
|
|
19
|
+
for (const page of stage.readOnlyPages) for (const source of page.sources) {
|
|
20
|
+
if (!policy.rules.verificationPaths.includes(source.path)
|
|
21
|
+
|| sources.has(source.path) && sources.get(source.path) !== source.sha256
|
|
22
|
+
|| page.sources.filter(item => item.path === source.path).length !== 1) {
|
|
23
|
+
designFail('STAGE', 'Read-only context requires exact existing source hashes in the unchanged verification range');
|
|
24
|
+
}
|
|
25
|
+
sources.set(source.path, source.sha256);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export const executableDesignPages = policy => policy.rules.stage?.pageIds ?? policy.site.pages.map(page => page.id);
|
|
29
|
+
export const executableDesignEdges = policy => policy.rules.stage?.edgeIds ?? policy.site.edges.map(edge => edge.id);
|
|
30
|
+
export function designAcceptanceScope(policy) {
|
|
31
|
+
return { mode: policy.rules.stage ? 'stage' : 'whole-site', ...(policy.rules.stage ? { stageId: policy.rules.stage.id } : {}),
|
|
32
|
+
pageIds: executableDesignPages(policy), edgeIds: executableDesignEdges(policy),
|
|
33
|
+
readOnlyPageIds: (policy.rules.stage?.readOnlyPages ?? []).map(page => page.pageId),
|
|
34
|
+
claimsWholeBusinessCompletion: false };
|
|
35
|
+
}
|
|
36
|
+
export async function assertDesignStageSources(input, policy) {
|
|
37
|
+
const sources = new Map((policy.rules.stage?.readOnlyPages ?? []).flatMap(page => page.sources.map(source => [source.path, source.sha256])));
|
|
38
|
+
if (!sources.size) return;
|
|
39
|
+
const { repositoryIntegrityFile } = await import('./local-discipline-repository.mjs');
|
|
40
|
+
for (const [name, expected] of sources) {
|
|
41
|
+
const actual = await repositoryIntegrityFile(input.repositoryRoot, name);
|
|
42
|
+
if (actual.sha256 !== expected) designFail('STAGE_SOURCE_CHANGED', 'A frozen read-only context source changed; preserve this result and obtain a truthful new baseline');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function assertDesignStageWrite(policy, name) {
|
|
46
|
+
const expected = (policy.rules.stage?.readOnlyPages ?? []).flatMap(page => page.sources).find(source => source.path === name);
|
|
47
|
+
if (expected) designFail('STAGE_READ_ONLY', 'This source is frozen as read-only context in the original approved stage');
|
|
48
|
+
}
|
|
49
|
+
const sensitive = /token|secret|password|passphrase|credential|authorization|cookie|session.?id|private.?key|api.?key|^(?:auth|roles?|permissions?|authenticated|isAdmin|isLoggedIn|accessLevel)$/i;
|
|
50
|
+
function safeHandoffValue(value) {
|
|
51
|
+
if (value === null) return true;
|
|
52
|
+
if (/-----BEGIN .*PRIVATE KEY|\bBearer\s+|https?:\/\/[^\s/]+:[^\s/]+@/i.test(value)) return false;
|
|
53
|
+
let parsed; try { parsed = JSON.parse(value); } catch { return !/[?&](?:token|key|secret|password|signature)=/i.test(value); }
|
|
54
|
+
const pending = [parsed]; let nodes = 0;
|
|
55
|
+
while (pending.length) {
|
|
56
|
+
if (++nodes > 1024) return false;
|
|
57
|
+
const node = pending.pop();
|
|
58
|
+
if (node && typeof node === 'object') for (const [key, item] of Object.entries(node)) {
|
|
59
|
+
if (sensitive.test(key)) return false;
|
|
60
|
+
pending.push(item);
|
|
61
|
+
}
|
|
62
|
+
else if (typeof node === 'string' && /\bBearer\s+|[?&](?:token|secret|password|signature)=/i.test(node)) return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
export function validateDesignHandoffs(step, edge) {
|
|
67
|
+
const handoffs = browserStepHandoffs(step, edge);
|
|
68
|
+
if (!handoffs.length) return;
|
|
69
|
+
if (!['navigate', 'open'].includes(edge.kind) || step.action === 'fill' || step.mutations.length
|
|
70
|
+
|| step.storageHandoffs?.length && step.readBack
|
|
71
|
+
|| step.readBack && (step.readBack.jsonPointer || step.readBack.kind !== 'local-storage')
|
|
72
|
+
|| new Set(handoffs.map(item => `${item.area}\0${item.key}`)).size !== handoffs.length
|
|
73
|
+
|| handoffs.some(item => sensitive.test(item.key)
|
|
74
|
+
|| (item.method === 'removeItem') !== (item.after === null)
|
|
75
|
+
|| !safeHandoffValue(item.before) || !safeHandoffValue(item.after))) {
|
|
76
|
+
designFail('HANDOFF', 'Only an explicit navigation/open action may declare exact noncredential storage handoffs; no HTTP commit or success authority is granted');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { canonical } from './local-integrity.mjs';
|
|
2
|
+
|
|
3
|
+
export function browserStepHandoffs(step, edge) {
|
|
4
|
+
if (step.storageHandoffs?.length) return step.storageHandoffs;
|
|
5
|
+
if (!['navigate', 'open'].includes(edge.kind) || step.readBack?.kind !== 'local-storage') return [];
|
|
6
|
+
const proof = step.readBack;
|
|
7
|
+
return [{ area: 'local-storage', key: proof.key, method: proof.after === null ? 'removeItem' : 'setItem', count: 1,
|
|
8
|
+
before: proof.before === null ? null : canonical(proof.before), after: proof.after === null ? null : canonical(proof.after), format: 'json' }];
|
|
9
|
+
}
|
|
10
|
+
function normalized(value, format) {
|
|
11
|
+
if (value === null || format !== 'json') return value;
|
|
12
|
+
try { return canonical(JSON.parse(value)); } catch { return undefined; }
|
|
13
|
+
}
|
|
14
|
+
async function storageSnapshot(page) {
|
|
15
|
+
return page.evaluate(() => {
|
|
16
|
+
let total = 0;
|
|
17
|
+
const read = store => {
|
|
18
|
+
if (store.length > 256) throw new Error('storage observation limit');
|
|
19
|
+
return Object.fromEntries(Array.from({ length: store.length }, (_, index) => {
|
|
20
|
+
const key = store.key(index), value = store.getItem(key); total += key.length + value.length;
|
|
21
|
+
if (total > 1_048_576) throw new Error('storage observation limit');
|
|
22
|
+
return [key, value];
|
|
23
|
+
}));
|
|
24
|
+
};
|
|
25
|
+
return { 'local-storage': read(localStorage), 'session-storage': read(sessionStorage) };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function browserStorage(base, parents) {
|
|
29
|
+
let before = new Map(), actor;
|
|
30
|
+
const empty = () => ({ 'local-storage': {}, 'session-storage': {} });
|
|
31
|
+
return {
|
|
32
|
+
async begin(context, page) {
|
|
33
|
+
actor = page; before = new Map();
|
|
34
|
+
for (const current of context.pages()) if (new URL(current.url()).origin === base.origin) before.set(current, await storageSnapshot(current));
|
|
35
|
+
},
|
|
36
|
+
async check(context, current, handoffs, writes, commits, failures) {
|
|
37
|
+
if (commits) return;
|
|
38
|
+
const same = (value, expected, format) => normalized(value, format) === normalized(expected, format);
|
|
39
|
+
if (writes.length !== handoffs.length || handoffs.some(expected => writes.filter(event =>
|
|
40
|
+
[actor, current].includes(event.page) && event.area === expected.area && event.key === expected.key && event.method === expected.method
|
|
41
|
+
&& same(event.before, expected.before, expected.format) && same(event.after, expected.after, expected.format)).length !== 1)) {
|
|
42
|
+
failures.push('unexpected-browser-storage-write');
|
|
43
|
+
}
|
|
44
|
+
for (const page of context.pages()) {
|
|
45
|
+
if (new URL(page.url()).origin !== base.origin) continue;
|
|
46
|
+
const original = before.get(page) ?? before.get(parents.get(page)) ?? empty(), after = await storageSnapshot(page);
|
|
47
|
+
for (const area of ['local-storage', 'session-storage']) {
|
|
48
|
+
const expected = { ...original[area] };
|
|
49
|
+
const applies = area === 'local-storage' || page === actor || page === current && !before.has(page);
|
|
50
|
+
for (const change of handoffs.filter(value => applies && value.area === area)) {
|
|
51
|
+
const value = Object.hasOwn(expected, change.key) ? expected[change.key] : null;
|
|
52
|
+
if (!same(value, change.before, change.format)) failures.push('handoff-before-state');
|
|
53
|
+
if (change.after === null) delete expected[change.key]; else expected[change.key] = change.after;
|
|
54
|
+
}
|
|
55
|
+
if (Object.keys(after[area]).length !== Object.keys(expected).length || Object.keys(expected).some(key => {
|
|
56
|
+
const declaration = handoffs.find(value => applies && value.area === area && value.key === key);
|
|
57
|
+
return !Object.hasOwn(after[area], key) || !same(after[area][key], expected[key], declaration?.format);
|
|
58
|
+
})) failures.push('handoff-storage-state');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHook } from 'node:async_hooks';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const sdkRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
function captureFsStack(exclude) {
|
|
8
|
+
const value = {}; Error.captureStackTrace(value, exclude); return value;
|
|
9
|
+
}
|
|
10
|
+
export function fsTraceStack(stack, root = sdkRoot) {
|
|
11
|
+
const frames = [];
|
|
12
|
+
for (const line of String(stack ?? '').split('\n').slice(1, 40)) {
|
|
13
|
+
const match = /(?:\()?((?:file:\/\/\/|node:)[^()\s]+):(\d+):(\d+)\)?$/.exec(line.trim());
|
|
14
|
+
if (!match) continue;
|
|
15
|
+
const [, source, row, column] = match; let module;
|
|
16
|
+
if (source.startsWith('node:')) module = source;
|
|
17
|
+
else {
|
|
18
|
+
let filename; try { filename = fileURLToPath(source); } catch { continue; }
|
|
19
|
+
const relative = path.relative(root, filename);
|
|
20
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || !/^[A-Za-z0-9_./-]+\.mjs$/.test(relative)) continue;
|
|
21
|
+
if (relative === 'fs-request-trace.mjs') continue;
|
|
22
|
+
module = relative;
|
|
23
|
+
}
|
|
24
|
+
frames.push({ module, line: Number(row), column: Number(column) });
|
|
25
|
+
if (frames.length >= 12) break;
|
|
26
|
+
}
|
|
27
|
+
return frames;
|
|
28
|
+
}
|
|
29
|
+
export function createFsRequestTrace({ hookFactory = createHook, now = () => performance.now(),
|
|
30
|
+
repeat = setInterval, cancel = clearInterval, write = value => process.stderr.write(`${JSON.stringify(value)}\n`),
|
|
31
|
+
stack = captureFsStack, thresholdMs = 15_000, maximumPending = 128 } = {}) {
|
|
32
|
+
if (!Number.isSafeInteger(thresholdMs) || thresholdMs < 1 || !Number.isSafeInteger(maximumPending)
|
|
33
|
+
|| maximumPending < 1 || maximumPending > 128) throw new Error('Invalid bounded FS trace settings');
|
|
34
|
+
const pending = new Map(); let dropped = 0, reported = 0, enabled = true;
|
|
35
|
+
const emit = value => { try { write({ schemaVersion: 'entrorove.fs-request-trace/1.0', metadataOnly: true, ...value }); } catch {} };
|
|
36
|
+
const hook = hookFactory({
|
|
37
|
+
init: function fsInit(asyncId, type, triggerAsyncId) {
|
|
38
|
+
if ((type !== 'FSREQPROMISE' && type !== 'FSREQCALLBACK') || !enabled) return;
|
|
39
|
+
if (pending.size >= maximumPending) { dropped++; return; }
|
|
40
|
+
try { pending.set(asyncId, { asyncId, triggerAsyncId, resourceType: type, created: now(), stack: stack(fsInit), reported: false }); }
|
|
41
|
+
catch { dropped++; }
|
|
42
|
+
},
|
|
43
|
+
destroy(asyncId) { pending.delete(asyncId); },
|
|
44
|
+
promiseResolve(asyncId) { pending.delete(asyncId); },
|
|
45
|
+
});
|
|
46
|
+
const check = () => {
|
|
47
|
+
for (const request of pending.values()) {
|
|
48
|
+
const elapsedMs = Math.max(0, Math.floor(now() - request.created));
|
|
49
|
+
if (request.reported || elapsedMs < thresholdMs) continue;
|
|
50
|
+
request.reported = true; reported++;
|
|
51
|
+
let creationStack = [];
|
|
52
|
+
try { creationStack = fsTraceStack(typeof request.stack === 'string' ? request.stack : request.stack?.stack); } catch {}
|
|
53
|
+
request.stack = null;
|
|
54
|
+
emit({ kind: 'fs-request-stall', resourceType: request.resourceType, asyncId: request.asyncId,
|
|
55
|
+
triggerAsyncId: request.triggerAsyncId, elapsedMs, pendingCount: pending.size, dropped,
|
|
56
|
+
creationStack, creationStackAvailable: creationStack.length > 0, cancelsRequest: false, grantsExecutionAuthority: false });
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
hook.enable(); const timer = repeat(check, Math.min(1000, thresholdMs)); timer.unref?.();
|
|
60
|
+
const stop = () => {
|
|
61
|
+
if (enabled) { enabled = false; hook.disable(); cancel(timer); pending.clear(); }
|
|
62
|
+
return { enabled, pendingCount: pending.size, dropped, reported };
|
|
63
|
+
};
|
|
64
|
+
stop.inspect = () => ({ enabled, pendingCount: pending.size, dropped, reported });
|
|
65
|
+
return stop;
|
|
66
|
+
}
|
|
67
|
+
export const startFsTrace = () => createFsRequestTrace();
|
package/local-contracts.mjs
CHANGED
|
@@ -101,7 +101,7 @@ const scopeInteraction = object({ type: enumeration(['confirm', 'choice', 'multi
|
|
|
101
101
|
const scopeTask = object({ goal: { ...text, pattern: '\\S', maxLength: 12000 }, workType: enumeration(['implementation', 'documentation', 'calculation', 'merge', 'review', 'decision', 'diagnosis']),
|
|
102
102
|
difficulty: enumeration(['low', 'medium', 'high']), risk: enumeration(['low', 'medium', 'high']), crossModule: { type: 'boolean' }, parallel: { type: 'boolean' }, needParallel: { type: 'boolean' },
|
|
103
103
|
unclear: { type: 'boolean' }, architectureContract: { type: 'boolean' }, explicitAimlockRequested: { type: 'boolean' }, explicitEntroroveRequested: { type: 'boolean' }, userSpecifiedSkills: array(text),
|
|
104
|
-
requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
|
|
104
|
+
requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, authorization: record, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
|
|
105
105
|
const probe = { ...step, paths: probePaths, targetPaths: probePaths, task: scopeTask, baselineRef: text, snapshotId: identifier,
|
|
106
106
|
targetSymbols: array(text, 1), contextMapPath: relativePath, importContexts, contextRevisionDigest: sha256,
|
|
107
107
|
contextEvidence: { ...array(object({ path: relativePath, sha256 }, ['path', 'sha256'])), maxItems: 200 }, signerConfigPath: nullable(text),
|
|
@@ -100,8 +100,10 @@ export async function disciplineRepositoryObservation(input, root, { analyze = f
|
|
|
100
100
|
let graph;
|
|
101
101
|
try { graph = roots.length ? await importGraph(root, roots, input, disciplineAnalysisLimits) : { edges: [], dynamicSources: [], complete: true }; }
|
|
102
102
|
catch (error) {
|
|
103
|
-
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
|
|
104
|
-
|
|
103
|
+
if (!['ENTROROVE_IMPORT_UNRESOLVED', 'ENTROROVE_IMPORT_INVALID', 'ENTROROVE_IMPORT_UNSUPPORTED', 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED', 'ENTROROVE_SCOPE_LIMIT', 'ENTROROVE_BUDGET_SCOPE'].includes(error.code)) throw error;
|
|
104
|
+
const address = error.code === 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED'
|
|
105
|
+
? { path: error.sourcePath, sha256: error.sourceSha256, selectorDigest: error.selectorDigest } : {};
|
|
106
|
+
graph = { edges: [], dynamicSources: [], complete: false }; unresolved.push({ ...address, reason: error.code, message: error.message });
|
|
105
107
|
}
|
|
106
108
|
for (const [relative, content] of overrides) if (semanticSource(relative) && /\.(?:[cm]?[jt]sx?|vue)$/i.test(relative)) {
|
|
107
109
|
try {
|
package/local-invocations.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { withStateLock } from './local-state-lock.mjs';
|
|
6
|
+
import { servicePrerequisite } from './service-prerequisite.mjs';
|
|
6
7
|
|
|
7
8
|
const SHA = /^[a-f0-9]{64}$/;
|
|
8
9
|
const UUID = /^[a-f0-9-]{36}$/;
|
|
@@ -66,6 +67,11 @@ export async function appendInvocation(value, home = os.homedir()) {
|
|
|
66
67
|
|| !['remote', 'local'].includes(value.kind)) throw new Error('Invalid invocation ledger metadata');
|
|
67
68
|
const record = { schemaVersion: 'entrorove.invocation/1.0', eventId: randomUUID(), at: new Date().toISOString(),
|
|
68
69
|
kind: value.kind, operation: value.operation, requestId: value.requestId, event: value.event };
|
|
70
|
+
if (value.servicePrerequisite !== undefined) {
|
|
71
|
+
const prerequisite = servicePrerequisite({ status: 403 }, value.servicePrerequisite);
|
|
72
|
+
if (value.event !== 'rejected' || !prerequisite) throw new Error('Invalid service prerequisite rejection receipt');
|
|
73
|
+
record.servicePrerequisite = prerequisite;
|
|
74
|
+
}
|
|
69
75
|
if (value.durationMs !== undefined) {
|
|
70
76
|
if (!Number.isSafeInteger(value.durationMs) || value.durationMs < 0) throw new Error('Invalid local invocation duration');
|
|
71
77
|
record.durationMs = value.durationMs;
|
package/local-mutation-files.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { constants, promises as fs } from 'node:fs';
|
|
1
|
+
import nodeFs, { constants, promises as fs } from 'node:fs';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { setImmediate as yieldMetadata14 } from 'node:timers/promises';
|
|
5
6
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
6
7
|
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
7
8
|
|
|
8
9
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
9
10
|
export const mutationFail14 = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
11
|
+
let metadataObservations14 = 0;
|
|
12
|
+
// Observe metadata afresh without waiting for a queued filesystem completion.
|
|
13
|
+
// Yield between bounded groups so timers and signals still run during scans.
|
|
14
|
+
async function lstatMetadata14(target) {
|
|
15
|
+
if (++metadataObservations14 % 128 === 0) await yieldMetadata14();
|
|
16
|
+
return nodeFs.lstatSync(target);
|
|
17
|
+
}
|
|
10
18
|
export function mutationId14(value, field) {
|
|
11
19
|
if (typeof value !== 'string' || !ID.test(value)) mutationFail14('ENTROROVE_ID_INVALID', `${field} must be a safe identifier`);
|
|
12
20
|
return value;
|
|
@@ -46,11 +54,11 @@ export function mutationInside14(root, target, field = 'path') {
|
|
|
46
54
|
async function inspectPath(target, { allowMissing = false, final = 'file', code = 'ENTROROVE_FILE_INVALID' } = {}) {
|
|
47
55
|
const resolved = absolute(target, 'filesystem path');
|
|
48
56
|
const base = path.parse(resolved).root; const parts = path.relative(base, resolved).split(path.sep).filter(Boolean);
|
|
49
|
-
let current = base; const ancestors = [{ path: base, stat: await
|
|
57
|
+
let current = base; const ancestors = [{ path: base, stat: await lstatMetadata14(base) }];
|
|
50
58
|
if (!parts.length) return { target: resolved, stat: ancestors[0].stat, exists: true, ancestors };
|
|
51
59
|
for (const [index, part] of parts.entries()) {
|
|
52
60
|
current = path.join(current, part); let stat;
|
|
53
|
-
try { stat = await
|
|
61
|
+
try { stat = await lstatMetadata14(current); } catch (error) {
|
|
54
62
|
if (error.code === 'ENOENT' && allowMissing) return { target: resolved, exists: false, stat: null, ancestors };
|
|
55
63
|
throw error;
|
|
56
64
|
}
|
|
@@ -71,7 +79,7 @@ async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
|
|
|
71
79
|
for (const [index, part] of parts.entries()) {
|
|
72
80
|
const candidate = path.join(current, part);
|
|
73
81
|
let stat;
|
|
74
|
-
try { stat = await
|
|
82
|
+
try { stat = await lstatMetadata14(candidate); } catch (error) {
|
|
75
83
|
if (error.code === 'ENOENT' && allowMissing) return path.join(current, ...parts.slice(index));
|
|
76
84
|
throw error;
|
|
77
85
|
}
|
|
@@ -90,7 +98,7 @@ async function canonicalDirectoryPath(value, { allowMissing = false, code }) {
|
|
|
90
98
|
}
|
|
91
99
|
async function assertAncestors(item, code = 'ENTROROVE_STATE_CHANGED') {
|
|
92
100
|
for (const ancestor of item.ancestors) {
|
|
93
|
-
const stat = await
|
|
101
|
+
const stat = await lstatMetadata14(ancestor.path);
|
|
94
102
|
if (stat.isSymbolicLink() || !stat.isDirectory() || !sameNode(stat, ancestor.stat)) {
|
|
95
103
|
mutationFail14(code, 'mutation path ancestors changed during filesystem access');
|
|
96
104
|
}
|
package/local-probe.mjs
CHANGED
|
@@ -71,15 +71,19 @@ async function probeHandoffCandidate(input, root, candidate, signingOptions) {
|
|
|
71
71
|
}
|
|
72
72
|
function probeSigningOptions(options) {
|
|
73
73
|
if (!options || typeof options !== 'object' || Array.isArray(options)
|
|
74
|
-
|| Object.keys(options).some(key =>
|
|
75
|
-
|| options.maximumExpiresAt !== undefined && (!Number.isSafeInteger(options.maximumExpiresAt) || options.maximumExpiresAt < 1)
|
|
76
|
-
|
|
74
|
+
|| Object.keys(options).some(key => !['maximumExpiresAt', 'beforeSign'].includes(key))
|
|
75
|
+
|| options.maximumExpiresAt !== undefined && (!Number.isSafeInteger(options.maximumExpiresAt) || options.maximumExpiresAt < 1)
|
|
76
|
+
|| options.beforeSign !== undefined && typeof options.beforeSign !== 'function') {
|
|
77
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Internal probe signing options require a finite deadline or an original bound challenge callback');
|
|
77
78
|
}
|
|
78
|
-
return { maximumExpiresAt: options.maximumExpiresAt };
|
|
79
|
+
return { maximumExpiresAt: options.maximumExpiresAt, beforeSign: options.beforeSign };
|
|
79
80
|
}
|
|
80
81
|
export async function probe(input, options = {}) {
|
|
81
82
|
const signingOptions = probeSigningOptions(options);
|
|
82
83
|
rejectScopePrivileges(input);
|
|
84
|
+
if (signingOptions.beforeSign && Object.hasOwn(input, 'handoffCandidate')) {
|
|
85
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Temporary handoff candidates cannot renew an ordinary scope challenge');
|
|
86
|
+
}
|
|
83
87
|
const root = await scopeRoot(input.repositoryRoot);
|
|
84
88
|
const candidate = await verifiedScopeCandidate(input, root);
|
|
85
89
|
if (candidate && await handoffReadBudget(input)) return probeHandoffCandidate(input, root, candidate, signingOptions);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Durable per-operation limits. These are recovery limits, not an execution
|
|
2
|
+
// permission, and callers cannot enlarge them through request payloads.
|
|
3
|
+
const limits = Object.freeze({ attempt: 2, signing: 2, status: 3, prerequisite: 2 });
|
|
4
|
+
const fail = recovery => Object.assign(new Error('Automatic recovery limit reached; preserve the original task and pending evidence, and report the concrete failure before another attempt'),
|
|
5
|
+
{ code: 'ENTROROVE_RECOVERY_EXHAUSTED', recovery: structuredClone(recovery), automaticRetryAllowed: false });
|
|
6
|
+
export function consumeRecovery(state, kind, eventId) {
|
|
7
|
+
if (!Object.hasOwn(limits, kind)) throw new Error('Unknown recovery boundary');
|
|
8
|
+
const recovery = Object.hasOwn(state, 'recovery') ? state.recovery : { schemaVersion: 'entrorove.recovery-budget/1.0', counts: {} };
|
|
9
|
+
if (!recovery || recovery.schemaVersion !== 'entrorove.recovery-budget/1.0' || !recovery.counts || Array.isArray(recovery.counts)
|
|
10
|
+
|| Object.keys(recovery).some(key => !['schemaVersion', 'counts', 'events'].includes(key))
|
|
11
|
+
|| Object.entries(recovery.counts).some(([key, value]) => !Object.hasOwn(limits, key)
|
|
12
|
+
|| !Number.isSafeInteger(value) || value < 0 || value > limits[key])) {
|
|
13
|
+
throw Object.assign(new Error('Original recovery accounting is invalid'), { code: 'ENTROROVE_RECOVERY_STATE' });
|
|
14
|
+
}
|
|
15
|
+
if (recovery.events && (Array.isArray(recovery.events) || Object.entries(recovery.events).some(([key, value]) =>
|
|
16
|
+
!Object.hasOwn(limits, key) || typeof value !== 'string' || !value || value.length > 256 || !recovery.counts[key]))) {
|
|
17
|
+
throw Object.assign(new Error('Original recovery event is invalid'), { code: 'ENTROROVE_RECOVERY_STATE' });
|
|
18
|
+
}
|
|
19
|
+
if (eventId !== undefined && (typeof eventId !== 'string' || !eventId || eventId.length > 256)) throw new Error('Invalid recovery event');
|
|
20
|
+
if (eventId && recovery.events?.[kind] === eventId) return structuredClone(recovery);
|
|
21
|
+
if ((recovery.counts[kind] ?? 0) >= limits[kind]) throw fail(recovery);
|
|
22
|
+
recovery.counts[kind] = (recovery.counts[kind] ?? 0) + 1;
|
|
23
|
+
if (eventId) recovery.events = { ...recovery.events, [kind]: eventId };
|
|
24
|
+
state.recovery = recovery;
|
|
25
|
+
return structuredClone(recovery);
|
|
26
|
+
}
|
|
27
|
+
export function recoveryFailureFields(error) {
|
|
28
|
+
return error?.code === 'ENTROROVE_RECOVERY_EXHAUSTED' || error?.code === 'ENTROROVE_RECOVERY_STATE'
|
|
29
|
+
? { code: error.code, message: error.message, automaticRetryAllowed: false,
|
|
30
|
+
...(error.recovery ? { recovery: error.recovery } : {}) } : {};
|
|
31
|
+
}
|
|
@@ -21,6 +21,7 @@ import { uniConditionalVariants } from './local-scope-uni-conditional.mjs';
|
|
|
21
21
|
import { createUniBuildInspection } from './local-scope-uni-build.mjs';
|
|
22
22
|
import { createScopeBuildIO } from './local-scope-build-io.mjs';
|
|
23
23
|
import { observeBuildFacts } from './local-scope-build-facts.mjs';
|
|
24
|
+
import { scopeWriteFiles, scopeWriteObservation } from './scope-write-observation.mjs';
|
|
24
25
|
|
|
25
26
|
const execute = promisify(execFile);
|
|
26
27
|
const inspectionFreshness = new WeakMap();
|
|
@@ -247,7 +248,11 @@ function declarationModules(content, from) {
|
|
|
247
248
|
}
|
|
248
249
|
async function resolveImport(root, from, specifier, workspace, input, aliases, configPath, declarations) {
|
|
249
250
|
if (specifier.startsWith('/') || /^[A-Za-z]:/.test(specifier) || specifier.includes('\\') || /[\u0000-\u001f\u007f]/.test(specifier)) {
|
|
250
|
-
|
|
251
|
+
// This is a source expression, not permission to probe an absolute target.
|
|
252
|
+
// Strict dependency inspection still rejects it; whole-repository usage
|
|
253
|
+
// observation can retain this specific semantic uncertainty without I/O.
|
|
254
|
+
throw Object.assign(new Error('Source module address has no supported local projection; no target was inspected'),
|
|
255
|
+
{ code: 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED', sourcePath: from, selectorDigest: sha256(specifier) });
|
|
251
256
|
}
|
|
252
257
|
if (/^(?:https?:|file:|data:)/i.test(specifier)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'remote source imports cannot be inspected as local facts');
|
|
253
258
|
if (declarations.has(specifier)) return null;
|
|
@@ -367,7 +372,10 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
367
372
|
catch (error) {
|
|
368
373
|
const missing = error.code === 'ENTROROVE_IMPORT_UNRESOLVED' && specifier.startsWith('.')
|
|
369
374
|
? error.missingCandidates?.filter(candidate => pendingCreates.has(candidate)) : null;
|
|
370
|
-
if (error.code !== 'ENTROROVE_IMPORT_UNRESOLVED')
|
|
375
|
+
if (error.code !== 'ENTROROVE_IMPORT_UNRESOLVED') {
|
|
376
|
+
if (error.code === 'ENTROROVE_IMPORT_ADDRESS_UNSUPPORTED') error.sourceSha256 = read.sha256;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
371
379
|
unresolvedImports.push({ path: relative, specifier, reason: missing?.length ? 'approved-create-not-present' : 'source-import-unresolved',
|
|
372
380
|
candidatePaths: missing?.length ? missing : error.missingCandidates ?? [] });
|
|
373
381
|
dynamic = true;
|
|
@@ -435,6 +443,13 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
|
|
|
435
443
|
const verifyGraph = inspectionFreshness.get(graph), observedMetadata = graph.workspaceMetadata.map(file => ({ path: file.path, sha256: file.sha256 }));
|
|
436
444
|
inspectionFreshness.set(graph, async () => { await verifyGraph(); await stableScope(root, observedMetadata, input, scopeBuildSourceRead); });
|
|
437
445
|
const observedGit = await observeGitFacts(root, files, baselineRef, input); const history = observedGit.facts;
|
|
446
|
+
let writeScope;
|
|
447
|
+
if (input.scopeWritePaths !== undefined) {
|
|
448
|
+
const writable = scopeWriteFiles(files, input.scopeWritePaths);
|
|
449
|
+
const observed = await observeGitFacts(root, writable, baselineRef, input);
|
|
450
|
+
if (['available', 'head', 'baseline'].some(field => observed.facts[field] !== history[field])) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Write observation lost the full scope Git identity');
|
|
451
|
+
writeScope = scopeWriteObservation(writable, observed, knownFiles);
|
|
452
|
+
}
|
|
438
453
|
const modules = [...new Map(graph.nodes.map(node => [node.boundary.path, node.boundary])).values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
439
454
|
await stableScope(root, files, input);
|
|
440
455
|
await stableScope(root, modules.filter(module => module.manifestPath).map(module => ({ path: module.manifestPath, sha256: module.manifestDigest })), input, scopeBuildSourceRead);
|
|
@@ -445,7 +460,7 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
|
|
|
445
460
|
: sourceLines + files.filter(file => !file.exists).reduce((sum, file) => sum + (knownFiles.find(previous => previous.path === file.path)?.lines ?? 0), 0);
|
|
446
461
|
return { targetPaths: [...new Set(requested)].sort(), fileCount: files.length, files, bytes, sourceLines, tokenEstimate: Math.ceil(bytes / 4), modules,
|
|
447
462
|
crossModule: modules.length > 1, risk: observedScopeRisk(files), importGraph: graph, git: history,
|
|
448
|
-
estimatedChangedLines };
|
|
463
|
+
estimatedChangedLines, ...(writeScope ? { writeScope } : {}) };
|
|
449
464
|
}
|
|
450
465
|
async function contextEvidence(root, graph, input) {
|
|
451
466
|
if (input.contextEvidence === undefined) return;
|
|
@@ -37,8 +37,8 @@ function binding(input, result) {
|
|
|
37
37
|
}
|
|
38
38
|
async function signingChallenge(input, beforeSign, maximumExpiresAt) {
|
|
39
39
|
if (beforeSign === undefined) return { input, maximumExpiresAt };
|
|
40
|
-
if (typeof beforeSign !== 'function' ||
|
|
41
|
-
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Challenge renewal
|
|
40
|
+
if (typeof beforeSign !== 'function' || Object.hasOwn(input, 'handoffCandidate')) {
|
|
41
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Challenge renewal requires an original bound scope observation');
|
|
42
42
|
}
|
|
43
43
|
const fresh = await beforeSign();
|
|
44
44
|
if (!fresh || Object.keys(fresh).sort().join(',') !== 'challenge,maximumExpiresAt'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { prerequisiteFields } from './service-prerequisite.mjs';
|
|
2
|
+
export { startFsTrace } from './fs-request-trace.mjs';
|
|
3
|
+
|
|
4
|
+
const flag = '--local-source';
|
|
5
|
+
const commands = new Set(['invoke', 'native', 'scope-connect', 'scope-context', 'scope-amend', 'mutation-connect',
|
|
6
|
+
'mutation-settle', 'workflow-observe-step', 'cloud-task', 'cloud-status', 'cloud-watch', 'feedback', 'status',
|
|
7
|
+
'flows', 'usage', 'billing', 'recurring', 'host-message', 'queue', 'local', 'review']);
|
|
8
|
+
const documents = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md',
|
|
9
|
+
'REUSE-DISCIPLINE.md', 'DELIVERY-BOUNDARIES.md', 'DESIGN-GATES.md'];
|
|
10
|
+
const fail = message => { throw Object.assign(new Error(message), { code: 'ENTROROVE_LOCAL_SOURCE_IDENTITY' }); };
|
|
11
|
+
let announced = false;
|
|
12
|
+
|
|
13
|
+
export function localSourceArguments(args) {
|
|
14
|
+
const count = args.filter(value => value === flag).length;
|
|
15
|
+
const tracing = args.filter(value => value === '--trace-fs').length;
|
|
16
|
+
if (!count && !tracing) return { args, localSource: false };
|
|
17
|
+
if (!count || tracing > 1 || tracing && args.at(-2) !== '--trace-fs') fail('--trace-fs requires explicit --local-source and must immediately precede it');
|
|
18
|
+
if (count !== 1 || args.at(-1) !== flag || !commands.has(args[0])) {
|
|
19
|
+
fail('Use --local-source exactly once, as the final argument of an SDK operation; installation and account configuration are excluded');
|
|
20
|
+
}
|
|
21
|
+
return { args: args.slice(0, tracing ? -2 : -1), localSource: true, ...(tracing ? { traceFs: true } : {}) };
|
|
22
|
+
}
|
|
23
|
+
export function verifyLocalSourceInstall(target, managed, bundle) {
|
|
24
|
+
if (!managed || typeof managed !== 'object' || managed.version !== bundle?.manifest?.version
|
|
25
|
+
|| !/^[a-f0-9]{64}$/.test(managed.packageDigest ?? '') || !/^[a-f0-9]{64}$/.test(bundle.packageDigest ?? '')
|
|
26
|
+
|| Object.keys(managed.files ?? {}).sort().join('\0') !== [...documents].sort().join('\0')
|
|
27
|
+
|| documents.some(name => managed.files[name] !== bundle.hashes[name])) {
|
|
28
|
+
fail('Local unpublished source requires the existing pristine managed eight documents at the same version; preserve the installation and repair this mismatch explicitly');
|
|
29
|
+
}
|
|
30
|
+
return { schemaVersion: 'entrorove.local-source-execution/1.0', status: 'verified', mode: 'local-unpublished-source',
|
|
31
|
+
declaredVersion: bundle.manifest.version, managedDirectory: target, sourceDigest: bundle.packageDigest,
|
|
32
|
+
managedPackageDigest: managed.packageDigest, published: false, managedUnchanged: true,
|
|
33
|
+
grantsExecutionAuthority: false, managedInstallationPresent: true, updated: false };
|
|
34
|
+
}
|
|
35
|
+
export function localSourceInstall(target, managed, bundle, { explicit, hostId, hooks } = {}) {
|
|
36
|
+
if (explicit || hostId !== undefined || hooks) fail('Local source execution cannot install, replace, clean up or configure hooks');
|
|
37
|
+
const verified = verifyLocalSourceInstall(target, managed, bundle);
|
|
38
|
+
if (!announced) { process.stdout.write(`${JSON.stringify(verified)}\n`); announced = true; }
|
|
39
|
+
return verified;
|
|
40
|
+
}
|
|
41
|
+
export function cliErrorText(error) {
|
|
42
|
+
const prerequisite = prerequisiteFields(error);
|
|
43
|
+
return prerequisite.servicePrerequisite ? JSON.stringify({ status: 'blocked', code: error.code,
|
|
44
|
+
message: error.message, ...prerequisite, ...(error.requestId ? { requestId: error.requestId } : {}),
|
|
45
|
+
...(error.outcomeUnknown ? { outcomeUnknown: true, repeatPost: false } : {}) })
|
|
46
|
+
: error instanceof SyntaxError ? 'Invalid JSON data' : error.message;
|
|
47
|
+
}
|
package/manifest.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
2
|
import { buildInvocation } from './remote-client.mjs';
|
|
3
3
|
import { mutationFail14 } from './local-mutation-files.mjs';
|
|
4
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
4
5
|
|
|
5
6
|
const fail = message => mutationFail14('ENTROROVE_MUTATION_CHALLENGE_REFRESH', message);
|
|
6
7
|
export async function refreshExpiredMutationChallenge(context, callbacks) {
|
|
@@ -36,6 +37,8 @@ export async function refreshExpiredMutationChallenge(context, callbacks) {
|
|
|
36
37
|
await save(location, state);
|
|
37
38
|
}
|
|
38
39
|
if (canonical(state.refreshOriginal.challenged.challenge) !== canonical(old)) fail('Original archived challenge binding changed');
|
|
40
|
+
consumeRecovery(state, 'signing', old.challengeId);
|
|
41
|
+
await save(location, state);
|
|
39
42
|
const ref = { challengeId: old.challengeId, challengeDigest: sha256(canonical(old)) };
|
|
40
43
|
const input = { ...Object.fromEntries(fields.map(field => [field, identity[field]])),
|
|
41
44
|
...(identity.previousPassId ? { previousPassId: identity.previousPassId } : {}),
|
package/mutation-client.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import { readMutationConnection as readConnection } from './mutation-connection-
|
|
|
21
21
|
import { readMutationEnvironmentObservation } from './local-mutation-environment-observation.mjs';
|
|
22
22
|
import { refreshExpiredMutationChallenge } from './mutation-challenge-refresh.mjs';
|
|
23
23
|
import { scopeContextObservation, scopeContextSnapshotInput } from './scope-context-observation.mjs';
|
|
24
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite, prerequisiteFields } from './service-prerequisite.mjs';
|
|
25
|
+
import { consumeRecovery, recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
24
26
|
|
|
25
27
|
const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'runnerId', 'paths'];
|
|
26
28
|
const fail = message => mutationFail14('ENTROROVE_MUTATION_CONNECTION_BINDING', message);
|
|
@@ -44,13 +46,16 @@ async function recoverRemote(pending, dependencies) {
|
|
|
44
46
|
const token = await dependencies.readCredential(dependencies.home), origin = trustedOrigin(dependencies.apiOrigin());
|
|
45
47
|
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, { headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
46
48
|
const result = await parseTransportResponse(response, 'original mutation connection status');
|
|
49
|
+
const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
|
|
50
|
+
if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
|
|
47
51
|
if (!response.ok || result?.output?.requestId !== pending.requestId || ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
|
|
48
52
|
result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
|
|
49
53
|
verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
|
|
50
54
|
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
51
55
|
await appendInvocation({ kind: 'remote', operation: 'status', requestId: pending.requestId, event: 'status', usage: result.usage ?? [] }, dependencies.home);
|
|
52
56
|
return { response, result };
|
|
53
|
-
} catch { throw unknown(pending.requestId)
|
|
57
|
+
} catch (error) { throw Object.assign(unknown(pending.requestId),
|
|
58
|
+
error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
|
|
54
59
|
}
|
|
55
60
|
async function knownNotSent(error, pending, home) {
|
|
56
61
|
if (error?.requestId && error.requestId !== pending.requestId || error?.retryableBeforeTransmission === true) return true;
|
|
@@ -62,22 +67,27 @@ async function knownNotSent(error, pending, home) {
|
|
|
62
67
|
async function remote(stage, operation, input, state, location, dependencies) {
|
|
63
68
|
const requestId = buildInvocation(operation, input).requestId;
|
|
64
69
|
let response, result;
|
|
65
|
-
|
|
70
|
+
const existing = Boolean(state.pending);
|
|
71
|
+
if (existing) {
|
|
66
72
|
if (state.pending.stage !== stage || state.pending.operation !== operation || canonical(state.pending.input) !== canonical(input)) {
|
|
67
73
|
fail('Recover the original mutation request before another operation or input');
|
|
68
74
|
}
|
|
75
|
+
}
|
|
76
|
+
if (existing && !await retryKnownPrerequisite(state, location, save)) {
|
|
77
|
+
consumeRecovery(state, 'status'); await save(location, state);
|
|
69
78
|
({ response, result } = await recoverRemote(state.pending, dependencies));
|
|
70
79
|
} else {
|
|
71
|
-
state.pending = { stage, operation, input, requestId };
|
|
80
|
+
if (!existing) state.pending = { stage, operation, input, requestId };
|
|
72
81
|
try { await save(location, state); } catch { throw notSent(requestId); }
|
|
73
82
|
try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
|
|
74
83
|
catch (error) {
|
|
84
|
+
if (await retainPrerequisite(error, state, location, save)) throw error;
|
|
75
85
|
if (await knownNotSent(error, state.pending, dependencies.home)) {
|
|
76
86
|
delete state.pending;
|
|
77
87
|
try { await save(location, state); } catch { throw notSent(requestId); }
|
|
78
88
|
throw notSent(requestId);
|
|
79
89
|
}
|
|
80
|
-
throw unknown(requestId);
|
|
90
|
+
throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
delete state.pending;
|
|
@@ -294,6 +304,7 @@ async function runMutationClient(operation, repositoryRoot, input, dependencies,
|
|
|
294
304
|
if (error?.outcomeUnknown === true && requestId) return {
|
|
295
305
|
schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: input?.flowId, requestId,
|
|
296
306
|
code: 'ENTROROVE_MUTATION_CONNECTION_OUTCOME_UNKNOWN', outcomeUnknown: true, recoverable: true,
|
|
307
|
+
...prerequisiteFields(error),
|
|
297
308
|
message: `Original mutation request outcome is unknown; rerun ${operation} with the same input to recover only its original GET, without repeating its POST`,
|
|
298
309
|
nextAction: { operation, requestId, method: 'GET', reuseOriginalInput: true, repeatPost: false },
|
|
299
310
|
};
|
|
@@ -301,11 +312,12 @@ async function runMutationClient(operation, repositoryRoot, input, dependencies,
|
|
|
301
312
|
const knownBeforeTransmission = preflight || error?.knownBeforeTransmission === true;
|
|
302
313
|
return { schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: input?.flowId,
|
|
303
314
|
code: typed ? error.code : 'ENTROROVE_MUTATION_CONNECTION_LOCAL_PREREQUISITE',
|
|
315
|
+
...prerequisiteFields(error),
|
|
304
316
|
message: typed ? error.message : 'Mutation connection stopped at a local prerequisite; preserve its journal and repair before reusing the original input',
|
|
305
317
|
...(requestId ? { requestId } : {}), ...(knownBeforeTransmission ? { knownBeforeTransmission: true } : {}),
|
|
306
318
|
recovery: { operation, reuseOriginalInput: true, preserveJournal: true,
|
|
307
319
|
...(knownBeforeTransmission ? { remoteAttemptedThisRun: false } : {}) },
|
|
308
|
-
...(error?.output ? { output: error.output } : {}) };
|
|
320
|
+
...(error?.output ? { output: error.output } : {}), ...recoveryFailureFields(error) };
|
|
309
321
|
}
|
|
310
322
|
}
|
|
311
323
|
export const runMutationConnect = (repositoryRoot, input, dependencies) => runMutationClient('mutation-connect', repositoryRoot, input, dependencies, connectMutation);
|
package/native-client.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { canonical, sha256 } from './local-integrity.mjs';
|
|
|
3
3
|
import { postInvocation } from './remote-client.mjs';
|
|
4
4
|
import { presentInvocationNotices } from './ide-presentation.mjs';
|
|
5
5
|
import { trustedOrigin } from './remote-transport.mjs';
|
|
6
|
+
import { discoveryPrerequisite } from './service-prerequisite.mjs';
|
|
6
7
|
|
|
7
8
|
const domains = new Set(['aimlock', 'blueprint', 'calctool', 'swarm', 'validator', 'mergeguard', 'archguard', 'confirm-protocol']);
|
|
8
9
|
const runtimeCodes = { aimlock: 'R3mQ8kWpXn', blueprint: 'wvz6zmRWmX', calctool: 'KKyA6xljUX', swarm: 'zj7fTPVh4p',
|
|
@@ -36,7 +37,9 @@ export async function invokeNativeResult(domain, operation, input, dependencies)
|
|
|
36
37
|
const token = await dependencies.readCredential(home);
|
|
37
38
|
let feedbackRequired = operation !== 'capabilities';
|
|
38
39
|
if (operation !== 'capabilities') {
|
|
39
|
-
|
|
40
|
+
let discovery;
|
|
41
|
+
try { discovery = await postInvocation(buildNativeInvocation(domain, 'capabilities', {}, dependencies.apiOrigin()), token, home); }
|
|
42
|
+
catch (error) { throw discoveryPrerequisite(error, call.requestId); }
|
|
40
43
|
if (!discovery.response.ok || discovery.result.output.status !== 'succeeded') throw new Error('Native capability discovery failed');
|
|
41
44
|
const found = discovery.result.output;
|
|
42
45
|
const extension = (found.capabilities ?? found).responseExtensions;
|