@entrorove/skill 0.1.73 → 0.1.81
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 +7 -2
- package/CHANGELOG.md +51 -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 +10 -6
- 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-approved-process.mjs +5 -1
- package/local-cli.mjs +40 -1
- 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 +16 -2
- package/release-source-groups.mjs +2 -0
- package/release-state.json +70 -55
- package/release.mjs +6 -1
- package/releases.json +48 -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 +39 -8
- 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 +3 -0
- package/workflow-observation-connection.mjs +4 -1
- package/workflow-observation-interruption.mjs +12 -0
- package/workflow-observation-recovery.mjs +42 -0
- package/workflow-observation-remote.mjs +13 -5
- package/workflow-observation-runner.mjs +13 -6
package/releases.json
CHANGED
|
@@ -1,4 +1,52 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.81",
|
|
4
|
+
"date": "2026-10-23",
|
|
5
|
+
"zh": "仓库不再保留自动化测试与调试脚本,发布内容与安装包只包含运行所需文件。八个核心技能的实现与权限边界保持不变。",
|
|
6
|
+
"en": "The repository no longer carries automated tests or debugging scripts; the published tarball contains runtime files only. The eight core skill implementations and their authority boundaries are unchanged."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.1.80",
|
|
10
|
+
"date": "2026-10-22",
|
|
11
|
+
"zh": "更新了技能运行和受管安装、能力覆盖映射、本地执行命令。",
|
|
12
|
+
"en": "Updated skill runtime and managed installation, capability coverage map, local execution command."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.1.79",
|
|
16
|
+
"date": "2026-10-22",
|
|
17
|
+
"zh": "修复干净安装后首次范围连接的服务公钥发现与私有绑定;补齐原任务本地观察的中断恢复并保留失败、未知和真实回执。配套服务修复范围修订路径排序,避免数据库语言环境误拒合法签名。八个核心技能的原实现与权限边界保持。",
|
|
18
|
+
"en": "Fix authenticated public authority discovery and private pinning on the first scope connection after a clean install. Complete original-task local observation recovery while preserving failures, unknown outcomes and real receipts. The companion service fixes canonical scope revision path ordering across database locales. Preserve the eight original skill implementations and authority boundaries."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.1.78",
|
|
22
|
+
"date": "2026-10-21",
|
|
23
|
+
"zh": "完整交付原八技能的一次入口授权、范围内衔接与有界恢复;修复已实际取消的弹窗先于点击回执关闭时的原窗口接续,不重发点击,原父窗口、精确路由、安全签名、快照及回执保护保持。",
|
|
24
|
+
"en": "Deliver the original eight skills with one task entrance approval, scoped handoffs and bounded recovery. Preserve the original opener when an actual popup cancellation closes before its click acknowledgement, without replaying the click or weakening exact routes, signatures, snapshots or receipts."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.1.77",
|
|
28
|
+
"date": "2026-10-21",
|
|
29
|
+
"zh": "未发布候选,内容并入 0.1.78:正式发布八技能一次入口授权、范围内衔接和有界恢复,保留原八技能核心、安全签名、快照及回执;兼容旧范围续签,并验证已发现能力清单复用时仍拒绝非法输入。",
|
|
30
|
+
"en": "Unpublished candidate, incorporated into 0.1.78: Publish the complete original eight skills with one task entrance approval, scoped handoffs and bounded recovery, preserving native cores, signatures, snapshots and receipts; retain legacy scope renewal compatibility and verify invalid input rejection with reused capability discovery."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.1.76",
|
|
34
|
+
"date": "2026-10-21",
|
|
35
|
+
"zh": "未发布候选,内容并入 0.1.78:正式交付八技能的一次入口授权、范围内衔接与有界恢复;兼容旧凭据续签的独立写入计量,保留签名、快照、回执和原批准范围校验。",
|
|
36
|
+
"en": "Unpublished candidate, incorporated into 0.1.78: Ship the eight-skill single-entrance authorization, scoped handoff and bounded recovery; preserve signed write accounting across legacy proof renewal without changing approved scope, signatures, snapshots or receipt checks."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.1.75",
|
|
40
|
+
"date": "2026-10-21",
|
|
41
|
+
"zh": "未发布候选,内容并入 0.1.78:正式发布复用已验证的八技能任务衔接与恢复改进;移除发布测试中未发布法律模块的夹具耦合。八技能核心实现保持原版。",
|
|
42
|
+
"en": "Unpublished candidate, incorporated into 0.1.78: Release the verified eight-skill task handoff and recovery improvements; remove unreleased legal-module coupling from release test fixtures. The eight original skill cores remain unchanged."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.1.74",
|
|
46
|
+
"date": "2026-10-21",
|
|
47
|
+
"zh": "未发布候选,内容并入 0.1.78:八技能衔接共用一次明确的任务入口批准,范围内复用原审批与已完成计算;修复范围状态读取、元数据扫描、独立可写计量及带查询参数/弹窗的设计验收。恢复次数持久有界,合法前置进度可继续,保留签名、快照、回执与未知结果只查询的保护,禁止跨项目耦合。",
|
|
48
|
+
"en": "Unpublished candidate, incorporated into 0.1.78: Reuse one explicit task entrance approval and completed skill computations within the approved boundary. Fix scope state reads, metadata scanning, independently signed write-role metering and query/popup design acceptance. Bound recovery durably while allowing verified prerequisite progress, preserving signatures, snapshots, receipts, GET-only unknown recovery and project isolation."
|
|
49
|
+
},
|
|
2
50
|
{
|
|
3
51
|
"version": "0.1.73",
|
|
4
52
|
"date": "2026-10-20",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// Schemas are discovery data, never authority. Each process still validates
|
|
4
|
+
// actual request/response bindings and the service enforces current permissions.
|
|
5
|
+
const entries = new Map(), maximumEntries = 8, lifetimeMs = 300_000;
|
|
6
|
+
const key = (origin, credential, home) => createHash('sha256').update(JSON.stringify([origin, credential, home])).digest('hex');
|
|
7
|
+
export async function discoveredCapabilities(origin, credential, home, fetch, { refresh = false } = {}) {
|
|
8
|
+
const identity = key(origin, credential, home), previous = entries.get(identity);
|
|
9
|
+
if (!refresh && previous && previous.expiresAt > Date.now()) return structuredClone(previous.value);
|
|
10
|
+
// Coalesce concurrent discovery for the same authenticated endpoint.
|
|
11
|
+
if (!refresh && previous?.pending) return structuredClone(await previous.pending);
|
|
12
|
+
const pending = fetch();
|
|
13
|
+
if (!entries.has(identity) && entries.size >= maximumEntries) entries.delete(entries.keys().next().value);
|
|
14
|
+
entries.set(identity, { pending });
|
|
15
|
+
try {
|
|
16
|
+
const value = structuredClone(await pending);
|
|
17
|
+
if (entries.get(identity)?.pending === pending) entries.set(identity, { value, expiresAt: Date.now() + lifetimeMs });
|
|
18
|
+
return structuredClone(value);
|
|
19
|
+
} catch (error) { if (entries.get(identity)?.pending === pending) entries.delete(identity); throw error; }
|
|
20
|
+
}
|
package/remote-client.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { acknowledgeAutomaticFeedback, verifyAutomaticFeedback } from './automat
|
|
|
7
7
|
import { ideClientHeaders, presentInvocationNotices } from './ide-presentation.mjs';
|
|
8
8
|
import { routeTaskEntry } from './task-entry-client.mjs';
|
|
9
9
|
import { cleanupCompletedSnapshots } from './snapshot-retention.mjs';
|
|
10
|
+
import { prerequisiteError, discoveryPrerequisite } from './service-prerequisite.mjs';
|
|
11
|
+
import { discoveredCapabilities } from './remote-capability-cache.mjs';
|
|
10
12
|
|
|
11
13
|
export const apiUrl = 'https://entrorove.com/api/skill/invoke';
|
|
12
14
|
|
|
@@ -98,9 +100,16 @@ async function invokeDiscovered(data, token, { home, apiOrigin }) {
|
|
|
98
100
|
const call = { ...buildInvocation(data.operation, data.input), url: `${trustedOrigin(apiOrigin())}/api/skill/invoke` };
|
|
99
101
|
let feedbackRequired = false;
|
|
100
102
|
if (data.operation !== 'capabilities') {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const fetch = async () => {
|
|
104
|
+
let discovery;
|
|
105
|
+
try { discovery = await postInvocation({ ...buildInvocation('capabilities', {}), url: call.url }, token, home); }
|
|
106
|
+
catch (error) { throw discoveryPrerequisite(error, call.requestId); }
|
|
107
|
+
if (!discovery.response.ok || discovery.result.output.status !== 'succeeded') throw new Error(`Capability discovery failed: HTTP ${discovery.response.status}`);
|
|
108
|
+
return discovery.result.output.capabilities;
|
|
109
|
+
};
|
|
110
|
+
let capabilities = await discoveredCapabilities(new URL(call.url).origin, token, home, fetch);
|
|
111
|
+
if (!capabilities?.operations?.includes(data.operation) || !capabilities?.operationSchemas?.[data.operation]) capabilities = await discoveredCapabilities(new URL(call.url).origin,
|
|
112
|
+
token, home, fetch, { refresh: true });
|
|
104
113
|
feedbackRequired = requiresAutomaticFeedback(data.operation,data.input,capabilities);
|
|
105
114
|
const schema = capabilities?.operationSchemas?.[data.operation];
|
|
106
115
|
if (!capabilities?.operations?.includes(data.operation) || !schema) throw new Error(`Capability discovery does not support ${data.operation}`);
|
|
@@ -135,6 +144,11 @@ export async function postInvocation(call, token, home, feedbackRequired = false
|
|
|
135
144
|
await logged({ event: 'outcome-unknown' }); throw unknownInvocation(call.requestId);
|
|
136
145
|
}
|
|
137
146
|
if (!response.ok && !result?.output) {
|
|
147
|
+
const prerequisite = prerequisiteError(response, result, { requestId: call.requestId });
|
|
148
|
+
if (prerequisite) {
|
|
149
|
+
await logged({ event: 'rejected', servicePrerequisite: prerequisite.servicePrerequisite });
|
|
150
|
+
throw Object.assign(prerequisite, prerequisite.knownBeforeEffects ? {} : { outcomeUnknown: true });
|
|
151
|
+
}
|
|
138
152
|
await logged({ event: response.status >= 500 ? 'outcome-unknown' : 'rejected' });
|
|
139
153
|
if (response.status >= 500) throw unknownInvocation(call.requestId);
|
|
140
154
|
const code = typeof result?.error === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(result.error) ? result.error : 'request-rejected';
|
|
@@ -199,7 +213,9 @@ export function createTaskMessageClient(dependencies) {
|
|
|
199
213
|
let feedbackRequired = false;
|
|
200
214
|
if (operation !== 'capabilities') {
|
|
201
215
|
const discovery = buildInvocation('capabilities', {});
|
|
202
|
-
|
|
216
|
+
let discovered;
|
|
217
|
+
try { discovered = await postInvocation({ ...discovery, url: `${origin}/api/skill/invoke` }, token, dependencies.home); }
|
|
218
|
+
catch (error) { throw discoveryPrerequisite(error, original.requestId); }
|
|
203
219
|
if (!discovered.response.ok || discovered.result.output.status !== 'succeeded') throw new Error('Task message capability discovery failed');
|
|
204
220
|
validateMachineCapability(operation, input, discovered.result.output.capabilities);
|
|
205
221
|
feedbackRequired = requiresAutomaticFeedback(operation,input,discovered.result.output.capabilities);
|
|
@@ -218,6 +234,8 @@ export function createTaskMessageClient(dependencies) {
|
|
|
218
234
|
const response = await readOnlyGet(machineOrigin(dependencies), `/api/skill/status/${id}`,
|
|
219
235
|
{ authorization: `Bearer ${token}` }, 'original task message status');
|
|
220
236
|
const result = await readResponse(response, 'task message status');
|
|
237
|
+
const prerequisite = prerequisiteError(response, result, { requestId: id, outcomeUnknown: true });
|
|
238
|
+
if (prerequisite) throw prerequisite;
|
|
221
239
|
if (!response.ok || !result?.output || result.output.requestId !== id) throw new Error('Original task message status is unavailable or invalid');
|
|
222
240
|
if (result.feedback) verifyAutomaticFeedback(result.feedback,id);
|
|
223
241
|
presentInvocationNotices(result,dependencies.onIdeEvent);
|
|
@@ -232,6 +250,8 @@ export async function queryStatus(requestId, dependencies) {
|
|
|
232
250
|
const token = await dependencies.readCredential(dependencies.home);
|
|
233
251
|
const response = await readOnlyGet(dependencies.apiOrigin(), `/api/skill/status/${encodeURIComponent(requestId)}`, { authorization: `Bearer ${token}` }, 'status')
|
|
234
252
|
const result = await readResponse(response, 'status');
|
|
253
|
+
const prerequisite = prerequisiteError(response, result, { requestId, outcomeUnknown: true });
|
|
254
|
+
if (prerequisite) throw prerequisite;
|
|
235
255
|
if (!response.ok || result?.output?.requestId !== requestId) throw new Error(`entrorove status failed: HTTP ${response.status}; requestId=${requestId}`);
|
|
236
256
|
await appendInvocation({ kind: 'remote', operation: 'status', requestId, event: 'status', usage: result.usage ?? [] }, dependencies.home);
|
|
237
257
|
if (result.feedback) verifyAutomaticFeedback(result.feedback,requestId);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prerequisiteFields } from './service-prerequisite.mjs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
@@ -10,8 +11,56 @@ import { amendmentRemote, scopeAmendmentStatus } from './scope-amendment-remote.
|
|
|
10
11
|
import { observeScopeAmendment } from './scope-amendment-observer.mjs';
|
|
11
12
|
import { retainScopeAmendmentReference } from './scope-amendment-reference.mjs';
|
|
12
13
|
import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
|
|
14
|
+
import { consumeRecovery, recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
13
15
|
|
|
14
16
|
const schemaVersion = 'entrorove.scope-amendment-connection/1.0';
|
|
17
|
+
const submissionFields = ['evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'evidenceSignature',
|
|
18
|
+
'attestation', 'attestationDigest', 'attestationRef', 'evidenceResult', 'applied', 'rejection'];
|
|
19
|
+
const signingChallenge = state => (state.signingChallenge ?? state.challenged).challenge;
|
|
20
|
+
function sameChallenge(value, original, location, attemptId, options) {
|
|
21
|
+
const fresh = scopeAmendmentChallenge(value, location, options);
|
|
22
|
+
const changing = new Set(['authorityReceipt', 'attemptId', 'challengeId', 'challenge', 'issuedAt', 'expiresAt']);
|
|
23
|
+
const immutable = challenge => Object.fromEntries(Object.entries(challenge).filter(([field]) => !changing.has(field)));
|
|
24
|
+
if (canonical(immutable(fresh)) !== canonical(immutable(original)) || fresh.attemptId !== attemptId
|
|
25
|
+
|| fresh.challenge === original.challenge || fresh.challengeId === original.challengeId) {
|
|
26
|
+
amendmentFail('Fresh amendment challenge changed its original task, signer, step, roles or authority head');
|
|
27
|
+
}
|
|
28
|
+
return fresh;
|
|
29
|
+
}
|
|
30
|
+
async function archiveSigningObservation(location, state) {
|
|
31
|
+
const checksum = sha256(canonical(state)), archive = path.join(location.directory, `${state.attemptId}-${checksum}.json`);
|
|
32
|
+
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) amendmentFail('Original signing observation history changed'); }
|
|
33
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
34
|
+
return checksum;
|
|
35
|
+
}
|
|
36
|
+
export async function renewScopeAmendmentChallenge(location, state, remote) {
|
|
37
|
+
if (submissionFields.some(field => Object.hasOwn(state, field)) || canonical(state.identity) !== canonical(location.identity)
|
|
38
|
+
|| state.snapshotStarted !== true || !state.snapshot || state.snapshot.snapshotId !== state.snapshotId
|
|
39
|
+
|| state.snapshot.flowId !== location.identity.flowId || state.snapshot.contractDigest !== location.identity.proposalDigest) {
|
|
40
|
+
amendmentFail('Fresh amendment challenge requires the same verified snapshot before any signed evidence or application');
|
|
41
|
+
}
|
|
42
|
+
const original = scopeAmendmentChallenge(state.challenged?.challenge, location, { allowExpired: true });
|
|
43
|
+
if (state.snapshot.stepId !== original.stepId) amendmentFail('Amendment snapshot replaced its original challenge step');
|
|
44
|
+
if (state.pending && (state.pending.stage !== 'signingChallenge' || !state.signingAttemptId)) {
|
|
45
|
+
amendmentFail('Recover the original pending amendment request before signing challenge renewal');
|
|
46
|
+
}
|
|
47
|
+
const retained = state.signingChallenge && sameChallenge(state.signingChallenge.challenge, original, location,
|
|
48
|
+
state.signingAttemptId, { allowExpired: true });
|
|
49
|
+
if (!state.pending && retained && Date.parse(retained.expiresAt) > Date.now()) {
|
|
50
|
+
return retained;
|
|
51
|
+
}
|
|
52
|
+
if (!state.pending && (!state.signingAttemptId || state.signingChallenge)) {
|
|
53
|
+
const archivedDigest = await archiveSigningObservation(location, state);
|
|
54
|
+
consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
|
|
55
|
+
delete state.signingChallenge; state.signingAttemptId = randomUUID();
|
|
56
|
+
state.signingRecovery = { archivedDigest, snapshotId: state.snapshotId, requiresSnapshotVerification: true,
|
|
57
|
+
grantsExecutionAuthority: false, recoveredAt: new Date().toISOString() };
|
|
58
|
+
await saveScopeAmendmentState(location, state);
|
|
59
|
+
}
|
|
60
|
+
const issued = await remote('signingChallenge', 'workflow-scope-challenge', { proposalId: location.identity.proposalId,
|
|
61
|
+
proposalDigest: location.identity.proposalDigest, receiverId: location.binding.runnerId, attemptId: state.signingAttemptId });
|
|
62
|
+
return sameChallenge(issued.challenge, original, location, state.signingAttemptId);
|
|
63
|
+
}
|
|
15
64
|
function validateProposal(status, input) {
|
|
16
65
|
const proposal = status.proposal;
|
|
17
66
|
if (!proposal || proposal.proposalId !== input.proposalId || proposal.proposalDigest !== input.proposalDigest
|
|
@@ -21,7 +70,7 @@ function validateProposal(status, input) {
|
|
|
21
70
|
async function validateApplied(output, state, location) {
|
|
22
71
|
const effective = output.effectiveAuthority, version = effective?.record?.schemaVersion === 'entrorove.scope-revision-authority/2.0' ? '2.0' : '1.0';
|
|
23
72
|
const record = scopeRevisionAuthority(effective?.record, `entrorove.scope-revision-authority/${version}`);
|
|
24
|
-
const challenge = scopeAmendmentChallenge(state
|
|
73
|
+
const challenge = scopeAmendmentChallenge(signingChallenge(state), location, { allowExpired: true }), refresh = challenge.purpose === 'scope-revision-refresh';
|
|
25
74
|
if (output.status !== 'succeeded' || output.grantsExecutionAuthority !== false || output.claimsCompletion !== false
|
|
26
75
|
|| output.scopeRevisionDigest !== sha256(canonical(record)) || effective.revisionDigest !== output.scopeRevisionDigest
|
|
27
76
|
|| output.revision !== effective.revision || record.revision !== effective.revision
|
|
@@ -59,18 +108,22 @@ async function archiveAttempt(location, state) {
|
|
|
59
108
|
const archive = path.join(location.directory, `${state.attemptId}.json`);
|
|
60
109
|
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) amendmentFail('Original scope amendment attempt history changed'); }
|
|
61
110
|
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
62
|
-
|
|
111
|
+
if (!state.applied) consumeRecovery(state, 'attempt', state.attemptId);
|
|
112
|
+
const completed = Boolean(state.applied);
|
|
113
|
+
for (const key of Object.keys(state)) if (!['schemaVersion', 'identity', ...(completed ? [] : ['recovery'])].includes(key)) delete state[key];
|
|
63
114
|
await saveScopeAmendmentState(location, state);
|
|
64
115
|
}
|
|
65
116
|
async function finish(location, state, dependencies) {
|
|
66
117
|
if (!state.evidenceDescriptor && !state.applied) {
|
|
67
|
-
const attestation = state.attestation ?? await observeScopeAmendment(location, state
|
|
68
|
-
|
|
118
|
+
const attestation = state.attestation ?? await observeScopeAmendment(location, state, {
|
|
119
|
+
beforeSign: () => renewScopeAmendmentChallenge(location, state,
|
|
120
|
+
(stage, operation, payload) => amendmentRemote(stage, operation, payload, state, location, dependencies)) });
|
|
121
|
+
const challenge = scopeAmendmentChallenge(signingChallenge(state), location, { allowExpired: true });
|
|
69
122
|
await saveLocalScopeEvidence(attestation, challenge.purpose === 'scope-revision-refresh' ? 'scope-refresh' : 'scope-revision',
|
|
70
123
|
challenge, state, location, saveScopeAmendmentState);
|
|
71
124
|
}
|
|
72
125
|
if (!state.applied) {
|
|
73
|
-
const attestationRef = await ensureScopeEvidence(state, location, dependencies, state
|
|
126
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies, signingChallenge(state), saveScopeAmendmentState);
|
|
74
127
|
await amendmentRemote('applied', 'workflow-scope-apply', { proposalId: location.identity.proposalId,
|
|
75
128
|
proposalDigest: location.identity.proposalDigest, attestationRef }, state, location, dependencies);
|
|
76
129
|
}
|
|
@@ -96,7 +149,8 @@ async function amend(location, dependencies) {
|
|
|
96
149
|
requiresOriginalOwnerApproval: true, scopeExpansionAutomaticallyAuthorized: false } };
|
|
97
150
|
if (state.challenged && !state.applied && !state.rejection) {
|
|
98
151
|
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true });
|
|
99
|
-
if (Date.parse(challenge.expiresAt) > Date.now()
|
|
152
|
+
if (Date.parse(challenge.expiresAt) > Date.now() || state.snapshot && state.snapshotStarted === true
|
|
153
|
+
&& !submissionFields.some(field => Object.hasOwn(state, field))) return finish(location, state, dependencies);
|
|
100
154
|
state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_CHALLENGE_EXPIRED', effects: 'none',
|
|
101
155
|
reason: 'signed-unused-challenge-expired-before-next-post' }; await saveScopeAmendmentState(location, state);
|
|
102
156
|
}
|
|
@@ -123,12 +177,14 @@ export async function runScopeAmend(repositoryRoot, input, dependencies) {
|
|
|
123
177
|
} catch (error) {
|
|
124
178
|
const requestId = /^[a-f0-9]{64}$/.test(error?.requestId ?? '') ? error.requestId : undefined;
|
|
125
179
|
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
180
|
+
...prerequisiteFields(error),
|
|
126
181
|
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_AMENDMENT_PREREQUISITE',
|
|
127
182
|
message: ['ENTROROVE_SCOPE_AMENDMENT_BINDING', 'ENTROROVE_SCOPE_AMENDMENT_STATE', 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN',
|
|
128
183
|
'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', 'ENTROROVE_SCOPE_AMENDMENT_MUTATION_PENDING'].includes(error?.code) ? error.message
|
|
129
184
|
: 'Scope amendment stopped; preserve the original proposal, signer, journal and actual evidence.',
|
|
130
185
|
...(requestId ? { requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
131
186
|
...(error?.feedback ? { feedback: error.feedback } : {}),
|
|
187
|
+
...recoveryFailureFields(error),
|
|
132
188
|
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-amend', ...(requestId ? { requestId } : { uploadId: error.uploadId }),
|
|
133
189
|
method: 'GET', reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
134
190
|
}
|
|
@@ -21,12 +21,14 @@ async function inspectFiles(location, challenge) {
|
|
|
21
21
|
physical.add(identity);
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
export async function observeScopeAmendment(location, state) {
|
|
25
|
-
const
|
|
24
|
+
export async function observeScopeAmendment(location, state, { beforeSign } = {}) {
|
|
25
|
+
const original = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: beforeSign !== undefined });
|
|
26
|
+
let challenge = original;
|
|
26
27
|
await inspectFiles(location, challenge);
|
|
27
28
|
if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
|
|
28
29
|
const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
|
|
29
30
|
snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
|
|
31
|
+
scopeWritePaths: challenge.files.filter(file => file.access !== 'read-only').map(file => file.path).sort(),
|
|
30
32
|
contractDigest: challenge.proposalDigest, importContexts: location.contextAuthority?.record.importContexts ?? location.binding.importContexts,
|
|
31
33
|
...(location.contextAuthority ? { contextRevisionDigest: location.contextAuthority.revisionDigest,
|
|
32
34
|
contextEvidence: location.contextAuthority.record.evidence } : {}),
|
|
@@ -56,7 +58,11 @@ export async function observeScopeAmendment(location, state) {
|
|
|
56
58
|
const observed = await attestScopeFacts({ ...input, ownerId: challenge.ownerId, taskDigest: challenge.taskDigest,
|
|
57
59
|
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge,
|
|
58
60
|
signerConfigPath: location.signerConfigPath }, { repositoryRoot: location.root, facts,
|
|
59
|
-
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt)
|
|
61
|
+
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt),
|
|
62
|
+
...(beforeSign === undefined ? {} : { beforeSign: async () => {
|
|
63
|
+
challenge = await beforeSign();
|
|
64
|
+
return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
|
|
65
|
+
} }) });
|
|
60
66
|
const memberMutation = await scopeAmendmentMutation(location, state, challenge, facts);
|
|
61
67
|
const core = { schemaVersion: 'entrorove.scope-revision-attestation/1.0', purpose: challenge.purpose,
|
|
62
68
|
proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest, expectedRevision: challenge.expectedRevision,
|
|
@@ -7,6 +7,8 @@ import { readInvocations } from './local-invocations.mjs';
|
|
|
7
7
|
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
8
8
|
import { amendmentFail } from './scope-amendment-wire.mjs';
|
|
9
9
|
import { retireMissingChallenge } from './scope-amendment-retirement.mjs';
|
|
10
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
11
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
10
12
|
|
|
11
13
|
const unknown = requestId => Object.assign(new Error('Scope amendment outcome is unknown; recover the original request with GET only'),
|
|
12
14
|
{ code: 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN', outcomeUnknown: true, requestId });
|
|
@@ -16,6 +18,8 @@ async function recover(pending, dependencies) {
|
|
|
16
18
|
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
|
|
17
19
|
headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
18
20
|
const result = parseTransportResponse(response, 'original scope amendment status');
|
|
21
|
+
const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
|
|
22
|
+
if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
|
|
19
23
|
if (response.status === 404 && result?.error === 'request-not-found') {
|
|
20
24
|
throw Object.assign(unknown(pending.requestId), { requestNotFound: true });
|
|
21
25
|
}
|
|
@@ -25,7 +29,8 @@ async function recover(pending, dependencies) {
|
|
|
25
29
|
verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
|
|
26
30
|
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
27
31
|
return { response, result };
|
|
28
|
-
} catch (error) { throw error?.requestNotFound ? error : unknown(pending.requestId)
|
|
32
|
+
} catch (error) { throw error?.requestNotFound ? error : Object.assign(unknown(pending.requestId),
|
|
33
|
+
error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
|
|
29
34
|
}
|
|
30
35
|
async function notSent(pending, home) {
|
|
31
36
|
try {
|
|
@@ -36,10 +41,14 @@ async function notSent(pending, home) {
|
|
|
36
41
|
export async function amendmentRemote(stage, operation, payload, state, location, dependencies) {
|
|
37
42
|
const input = { flowId: location.identity.flowId, operation, payload }, requestId = buildInvocation('continue', input).requestId;
|
|
38
43
|
let response, result;
|
|
39
|
-
|
|
44
|
+
const existing = Boolean(state.pending);
|
|
45
|
+
if (existing) {
|
|
40
46
|
if (state.pending.stage !== stage || state.pending.requestId !== requestId || canonical(state.pending.input) !== canonical(input)) {
|
|
41
47
|
amendmentFail('Recover the original amendment request before another operation');
|
|
42
48
|
}
|
|
49
|
+
}
|
|
50
|
+
if (existing && !await retryKnownPrerequisite(state, location, saveScopeAmendmentState)) {
|
|
51
|
+
consumeRecovery(state, 'status'); await saveScopeAmendmentState(location, state);
|
|
43
52
|
if (state.challengeRetirement) await retireMissingChallenge(state, location, dependencies, recover, notSent);
|
|
44
53
|
try { ({ response, result } = await recover(state.pending, dependencies)); }
|
|
45
54
|
catch (error) {
|
|
@@ -48,16 +57,18 @@ export async function amendmentRemote(stage, operation, payload, state, location
|
|
|
48
57
|
({ response, result } = await recover(state.pending, dependencies));
|
|
49
58
|
}
|
|
50
59
|
} else {
|
|
51
|
-
state.pending = { stage, operation: 'continue', input, requestId };
|
|
52
|
-
|
|
60
|
+
if (!existing) { state.pending = { stage, operation: 'continue', input, requestId };
|
|
61
|
+
await saveScopeAmendmentState(location, state); }
|
|
53
62
|
try { ({ response, result } = await invokeRemoteResult({ operation: 'continue', input }, dependencies)); }
|
|
54
63
|
catch (error) {
|
|
64
|
+
if (await retainPrerequisite(error, state, location, saveScopeAmendmentState)) throw error;
|
|
55
65
|
if (await notSent(state.pending, dependencies.home)) {
|
|
66
|
+
state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', effects: 'none', requestId };
|
|
56
67
|
delete state.pending; await saveScopeAmendmentState(location, state);
|
|
57
68
|
throw Object.assign(new Error('Scope amendment was not transmitted; repair its prerequisite and reuse the original input'),
|
|
58
69
|
{ code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
59
70
|
}
|
|
60
|
-
throw unknown(requestId);
|
|
71
|
+
throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|
|
@@ -80,5 +91,8 @@ export async function scopeAmendmentStatus(flowId, dependencies) {
|
|
|
80
91
|
input: { flowId, operation: 'workflow-scope-status', payload: {} } }, dependencies);
|
|
81
92
|
if (!response.ok || result?.output?.status !== 'succeeded') throw new Error();
|
|
82
93
|
return result.output.result ?? result.output;
|
|
83
|
-
} catch
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.servicePrerequisite) throw error;
|
|
96
|
+
amendmentFail('Current account cannot read its original live scope revision; no amendment mutation was sent');
|
|
97
|
+
}
|
|
84
98
|
}
|
|
@@ -2,6 +2,7 @@ import { canonical, sha256 } from './local-integrity.mjs';
|
|
|
2
2
|
import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
|
|
3
3
|
import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
|
|
4
4
|
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
5
|
+
import { retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
5
6
|
|
|
6
7
|
export async function retireMissingChallenge(state, location, dependencies, recover, notSent) {
|
|
7
8
|
const original = state.pending;
|
|
@@ -14,14 +15,15 @@ export async function retireMissingChallenge(state, location, dependencies, reco
|
|
|
14
15
|
if (attempt && (attempt.requestId !== call.requestId || canonical(attempt.input) !== canonical(input))) {
|
|
15
16
|
amendmentFail('Challenge retirement must retain its original preparation envelope');
|
|
16
17
|
}
|
|
17
|
-
if (!attempt) {
|
|
18
|
-
attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
|
|
18
|
+
if (!attempt || await retryKnownPrerequisite(state, location, saveScopeAmendmentState, 'challengeRetirement')) {
|
|
19
|
+
if (!attempt) attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
|
|
19
20
|
await saveScopeAmendmentState(location, state);
|
|
20
21
|
try {
|
|
21
22
|
const { result } = await invokeRemoteResult({ operation: 'scope-challenge-retire', input }, dependencies);
|
|
22
23
|
attempt.output = result.output; attempt.feedback = result.feedback; attempt.state = 'completed';
|
|
23
24
|
await saveScopeAmendmentState(location, state);
|
|
24
25
|
} catch (error) {
|
|
26
|
+
if (await retainPrerequisite(error, state, location, saveScopeAmendmentState, 'challengeRetirement')) throw error;
|
|
25
27
|
if (await notSent(attempt, dependencies.home)) {
|
|
26
28
|
attempt.state = 'not-sent'; await saveScopeAmendmentState(location, state);
|
|
27
29
|
}
|
|
@@ -10,17 +10,21 @@ import { trustedOrigin } from './remote-transport.mjs';
|
|
|
10
10
|
import { boundScopeContexts } from './scope-context-state.mjs';
|
|
11
11
|
|
|
12
12
|
const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
|
|
13
|
-
const
|
|
13
|
+
const maximumControlBytes = 1_000_000;
|
|
14
|
+
// Retained scope combines original facts and verified history, not a single
|
|
15
|
+
// signing control. Keep its aggregate bound separate from key/config files.
|
|
16
|
+
const maximumRetainedScopeBytes = 8_000_000;
|
|
14
17
|
// A journal retains the challenge, S proof, original pending request and full
|
|
15
18
|
// signed response together. Each signed authority retains its own 1 MB bound.
|
|
16
19
|
const maximumJournalBytes = 8_000_000;
|
|
17
|
-
async function privateItem(file, final = 'file', maximumBytes =
|
|
20
|
+
async function privateItem(file, final = 'file', maximumBytes = maximumControlBytes) {
|
|
18
21
|
const value = await mutationFile14(file, { final });
|
|
19
22
|
if (process.getuid && value.stat.uid !== process.getuid()
|
|
20
23
|
|| process.platform !== 'win32' && value.stat.mode & 0o077
|
|
21
|
-
|| final === 'file' &&
|
|
22
|
-
fail('Scope amendment controls must be
|
|
24
|
+
|| final === 'file' && value.stat.nlink !== 1) {
|
|
25
|
+
fail('Scope amendment controls must be private single-link files owned by the current OS account');
|
|
23
26
|
}
|
|
27
|
+
if (final === 'file' && value.stat.size > maximumBytes) fail(`Scope amendment file exceeds the ${maximumBytes} byte limit`);
|
|
24
28
|
return value;
|
|
25
29
|
}
|
|
26
30
|
async function privateText(file) {
|
|
@@ -68,7 +72,7 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
|
|
|
68
72
|
if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey
|
|
69
73
|
|| sha256(der) !== binding.keyId) fail('Original scope signing identity changed');
|
|
70
74
|
const originalStatePath = path.join(stateRoot, 'scope', sha256(root).slice(0, 32), input.flowId, 'state.json');
|
|
71
|
-
await privateItem(originalStatePath);
|
|
75
|
+
await privateItem(originalStatePath, 'file', maximumRetainedScopeBytes);
|
|
72
76
|
const saved = await readMutationJson14(originalStatePath);
|
|
73
77
|
const original = await retainedScope({ repositoryRoot: root, home, flowId: input.flowId,
|
|
74
78
|
stepId: saved.stepId, snapshotId: saved.originalSnapshot?.snapshotId }, root);
|
package/scope-context-client.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prerequisiteFields } from './service-prerequisite.mjs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { sign } from 'node:crypto';
|
|
3
4
|
import { canonical } from './local-integrity.mjs';
|
|
@@ -9,6 +10,7 @@ import { scopeContextLocation, effectiveScopeContexts, retainContextAuthority,
|
|
|
9
10
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
10
11
|
import { inspectContextEvidence, observeScopeContext, prepareScopeContext } from './scope-context-observer.mjs';
|
|
11
12
|
import { contextJournal, renewContextChallenge } from './scope-context-journal.mjs';
|
|
13
|
+
import { recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
12
14
|
import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
|
|
13
15
|
|
|
14
16
|
const schemaVersion = 'entrorove.scope-context-connection/1.0';
|
|
@@ -23,6 +25,9 @@ function proposalFor(status, input, location) {
|
|
|
23
25
|
}
|
|
24
26
|
function waiting(value, flowId) {
|
|
25
27
|
const status = value.proposal?.approval?.status;
|
|
28
|
+
if (status === 'approved') return { ...value, schemaVersion, status: 'succeeded', flowId,
|
|
29
|
+
grantsExecutionAuthority: false, claimsCompletion: false, nextOperation: 'scope-context',
|
|
30
|
+
nextInput: { action: 'apply', flowId, proposalId: value.proposal.proposalId, proposalDigest: value.proposal.proposalDigest } };
|
|
26
31
|
return { ...value, schemaVersion, status: status === 'declined' ? 'blocked' : 'needs-input', flowId,
|
|
27
32
|
grantsExecutionAuthority: false, claimsCompletion: false,
|
|
28
33
|
nextStep: value.nextStep ?? { operation: 'interaction-answer', requiresOriginalOwnerApproval: true,
|
|
@@ -118,9 +123,11 @@ export async function runScopeContext(repositoryRoot, input, dependencies) {
|
|
|
118
123
|
return await withStateLock(path.join(location.base, 'connection'), () => run(location, input, dependencies));
|
|
119
124
|
} catch (error) {
|
|
120
125
|
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
126
|
+
...prerequisiteFields(error),
|
|
121
127
|
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_CONTEXT_PREREQUISITE',
|
|
122
128
|
message: error?.code?.startsWith('ENTROROVE_') ? error.message : 'Context recovery stopped; preserve original signer, binding, journal and evidence.',
|
|
123
129
|
...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
130
|
+
...recoveryFailureFields(error),
|
|
124
131
|
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-context', method: 'GET',
|
|
125
132
|
...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
126
133
|
}
|
|
@@ -5,6 +5,7 @@ import { atomicMutationJson14, readMutationJson14 } from './local-mutation-files
|
|
|
5
5
|
import { contextFail } from './scope-context-wire.mjs';
|
|
6
6
|
import { readContextJournal, saveContextJournal } from './scope-context-state.mjs';
|
|
7
7
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
8
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
8
9
|
|
|
9
10
|
export async function archiveContextAttempt(location, state) {
|
|
10
11
|
if (!state.attemptId) return;
|
|
@@ -20,22 +21,27 @@ export async function contextJournal(location, input) {
|
|
|
20
21
|
let state = await readContextJournal(location, input);
|
|
21
22
|
if (!state.previous && reusableSnapshot(state, input)) {
|
|
22
23
|
await archiveContextAttempt(location, state);
|
|
24
|
+
consumeRecovery(state, 'attempt', state.signingAttemptId ?? state.attemptId);
|
|
23
25
|
for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId',
|
|
24
26
|
'evidenceDescriptor', 'evidenceSignature', 'evidencePrepared', 'attestationRef', 'evidenceResult']) delete state[field];
|
|
25
27
|
await saveContextJournal(location, state);
|
|
26
28
|
} else if (state.previous || !state.pending && !state.evidencePending && !state.applied && (state.rejection
|
|
27
29
|
|| input.action === 'status' && state.completed || input.action === 'apply' && state.completed)) {
|
|
28
|
-
|
|
30
|
+
const previous = state.previous ?? state;
|
|
31
|
+
await archiveContextAttempt(location, previous);
|
|
32
|
+
const recovery = previous.completed ? undefined : consumeRecovery(previous, 'attempt', previous.attemptId);
|
|
33
|
+
state = { ...(recovery ? { recovery } : {}) };
|
|
29
34
|
}
|
|
30
35
|
if (!state.attemptId) {
|
|
31
36
|
state = { schemaVersion: 'entrorove.scope-context-journal/1.0', localBindingDigest: location.localBindingDigest,
|
|
32
|
-
input, attemptId: randomUUID() }; await saveContextJournal(location, state);
|
|
37
|
+
input, attemptId: randomUUID(), ...(state.recovery ? { recovery: state.recovery } : {}) }; await saveContextJournal(location, state);
|
|
33
38
|
}
|
|
34
39
|
return state;
|
|
35
40
|
}
|
|
36
41
|
export async function renewContextChallenge(location, state, proposal, dependencies) {
|
|
37
42
|
if (state.pending || state.evidencePending) contextFail('Recover the original pending request before any challenge renewal');
|
|
38
43
|
await archiveContextAttempt(location, state);
|
|
44
|
+
consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
|
|
39
45
|
delete state.signingChallenge;
|
|
40
46
|
state.signingAttemptId = randomUUID(); await saveContextJournal(location, state);
|
|
41
47
|
const output = await contextRemote('signingChallenge', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
|
package/scope-context-remote.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFe
|
|
|
6
6
|
import { readInvocations } from './local-invocations.mjs';
|
|
7
7
|
import { saveContextJournal } from './scope-context-state.mjs';
|
|
8
8
|
import { contextFail } from './scope-context-wire.mjs';
|
|
9
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
10
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
9
11
|
|
|
10
12
|
const unknown = requestId => Object.assign(new Error('Context recovery outcome is unknown; query its original request with GET only'),
|
|
11
13
|
{ code: 'ENTROROVE_SCOPE_CONTEXT_UNKNOWN', outcomeUnknown: true, requestId });
|
|
@@ -15,6 +17,8 @@ async function recover(pending, dependencies) {
|
|
|
15
17
|
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
|
|
16
18
|
headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
17
19
|
const result = parseTransportResponse(response, 'original context recovery status');
|
|
20
|
+
const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
|
|
21
|
+
if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
|
|
18
22
|
if (result?.output?.requestId !== pending.requestId || !response.ok || ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
|
|
19
23
|
if (pending.operation !== 'scope-runner-context-status' || result.feedback) {
|
|
20
24
|
result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
|
|
@@ -22,7 +26,8 @@ async function recover(pending, dependencies) {
|
|
|
22
26
|
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
23
27
|
}
|
|
24
28
|
return { response, result };
|
|
25
|
-
} catch { throw unknown(pending.requestId)
|
|
29
|
+
} catch (error) { throw Object.assign(unknown(pending.requestId),
|
|
30
|
+
error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
|
|
26
31
|
}
|
|
27
32
|
async function notSent(pending, home) {
|
|
28
33
|
try {
|
|
@@ -32,20 +37,26 @@ async function notSent(pending, home) {
|
|
|
32
37
|
}
|
|
33
38
|
export async function contextRemote(stage, operation, input, state, location, dependencies, save = saveContextJournal) {
|
|
34
39
|
const requestId = buildInvocation(operation, input).requestId; let response, result;
|
|
35
|
-
|
|
40
|
+
const existing = Boolean(state.pending);
|
|
41
|
+
if (existing) {
|
|
36
42
|
if (state.pending.stage !== stage || state.pending.requestId !== requestId || state.pending.operation !== operation
|
|
37
43
|
|| canonical(state.pending.input) !== canonical(input)) contextFail('Recover the original context POST before another operation');
|
|
44
|
+
}
|
|
45
|
+
if (existing && !await retryKnownPrerequisite(state, location, save)) {
|
|
46
|
+
consumeRecovery(state, 'status'); await save(location, state);
|
|
38
47
|
({ response, result } = await recover(state.pending, dependencies));
|
|
39
|
-
}
|
|
40
|
-
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (!existing) { state.pending = { stage, operation, input, requestId }; await save(location, state); }
|
|
41
51
|
try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
|
|
42
52
|
catch (error) {
|
|
53
|
+
if (await retainPrerequisite(error, state, location, save)) throw error;
|
|
43
54
|
if (await notSent(state.pending, dependencies.home)) {
|
|
44
55
|
delete state.pending; await save(location, state);
|
|
45
56
|
throw Object.assign(new Error('Context request was not transmitted; repair its prerequisite and reuse the original input'),
|
|
46
57
|
{ code: 'ENTROROVE_SCOPE_CONTEXT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
47
58
|
}
|
|
48
|
-
throw unknown(requestId);
|
|
59
|
+
throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
62
|
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|