@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.
Files changed (72) hide show
  1. package/CAPABILITY-MAP.md +7 -2
  2. package/CHANGELOG.md +51 -0
  3. package/DESIGN-GATES.md +5 -9
  4. package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
  5. package/IDE-EXECUTION.md +21 -4
  6. package/REUSE-DISCIPLINE.md +3 -3
  7. package/SKILL.md +14 -4
  8. package/cli.mjs +10 -6
  9. package/cloud-mutation-authority.mjs +8 -2
  10. package/cloud-task-client.mjs +10 -0
  11. package/cloud-watch-client.mjs +8 -2
  12. package/design-browser-checks.mjs +18 -5
  13. package/design-browser-pages.mjs +96 -0
  14. package/design-browser-state.mjs +1 -0
  15. package/design-browser.mjs +36 -40
  16. package/design-contracts.mjs +4 -1
  17. package/design-declaration.mjs +3 -0
  18. package/design-frameworks.mjs +2 -1
  19. package/design-gate.mjs +3 -0
  20. package/design-operations.mjs +4 -1
  21. package/design-policy.mjs +12 -4
  22. package/design-release.mjs +6 -0
  23. package/design-research-network.mjs +82 -0
  24. package/design-research.mjs +8 -25
  25. package/design-runner.mjs +3 -0
  26. package/design-stage-contracts.mjs +12 -0
  27. package/design-stage.mjs +78 -0
  28. package/design-storage.mjs +63 -0
  29. package/fs-request-trace.mjs +67 -0
  30. package/local-approved-process.mjs +5 -1
  31. package/local-cli.mjs +40 -1
  32. package/local-contracts.mjs +1 -1
  33. package/local-discipline-repository.mjs +4 -2
  34. package/local-invocations.mjs +6 -0
  35. package/local-mutation-files.mjs +13 -5
  36. package/local-probe.mjs +8 -4
  37. package/local-recovery-budget.mjs +31 -0
  38. package/local-scope-repository.mjs +18 -3
  39. package/local-scope-signature.mjs +2 -2
  40. package/local-source-execution.mjs +47 -0
  41. package/manifest.json +1 -1
  42. package/mutation-challenge-refresh.mjs +3 -0
  43. package/mutation-client.mjs +17 -5
  44. package/native-client.mjs +4 -1
  45. package/package.json +16 -2
  46. package/release-source-groups.mjs +2 -0
  47. package/release-state.json +70 -55
  48. package/release.mjs +6 -1
  49. package/releases.json +48 -0
  50. package/remote-capability-cache.mjs +20 -0
  51. package/remote-client.mjs +24 -4
  52. package/scope-amendment-client.mjs +62 -6
  53. package/scope-amendment-observer.mjs +9 -3
  54. package/scope-amendment-remote.mjs +20 -6
  55. package/scope-amendment-retirement.mjs +4 -2
  56. package/scope-amendment-state.mjs +9 -5
  57. package/scope-context-client.mjs +7 -0
  58. package/scope-context-journal.mjs +8 -2
  59. package/scope-context-remote.mjs +16 -5
  60. package/scope-evidence-remote.mjs +16 -7
  61. package/scope-runner-client.mjs +39 -8
  62. package/scope-runner-journal.mjs +82 -5
  63. package/scope-write-observation.d.mts +4 -0
  64. package/scope-write-observation.mjs +43 -0
  65. package/service-prerequisite.mjs +87 -0
  66. package/task-entry-client.mjs +3 -0
  67. package/workflow-observation-client.mjs +3 -0
  68. package/workflow-observation-connection.mjs +4 -1
  69. package/workflow-observation-interruption.mjs +12 -0
  70. package/workflow-observation-recovery.mjs +42 -0
  71. package/workflow-observation-remote.mjs +13 -5
  72. package/workflow-observation-runner.mjs +13 -6
@@ -4,6 +4,7 @@ import { readScopeEvidence, retainScopeEvidence } from './scope-evidence-local.m
4
4
  import { describeScopeEvidence, evidenceBytes, evidenceFail, evidenceUpload, evidenceReference } from './scope-evidence-wire.mjs';
5
5
  import { requestTransport, putScopeEvidence, parseTransportResponse } from './remote-transport.mjs';
6
6
  import { ideClientHeaders } from './ide-presentation.mjs';
7
+ import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
7
8
 
8
9
  const unknown = uploadId => Object.assign(new Error('Scope evidence upload outcome is unknown; recover the original reservation with GET only'),
9
10
  { code: 'ENTROROVE_SCOPE_EVIDENCE_UNKNOWN', outcomeUnknown: true, uploadId });
@@ -24,10 +25,15 @@ function resultReference(value, upload, descriptor, location) {
24
25
  if (['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== upload[field])) evidenceFail('Stored scope evidence replaced its original account or task');
25
26
  return reference;
26
27
  }
27
- async function acceptResult(response, upload, state, location, save) {
28
+ async function acceptResult(response, upload, state, location, save, sentPut = false) {
28
29
  let value;
29
30
  try { value = parseTransportResponse(response, 'scope evidence upload'); }
30
31
  catch { throw unknown(upload.uploadId); }
32
+ const prerequisite = prerequisiteError(response, value, { uploadId: upload.uploadId });
33
+ if (prerequisite) {
34
+ if (sentPut && await retainPrerequisite(prerequisite, state, location, save, 'evidencePending')) throw prerequisite;
35
+ throw Object.assign(unknown(upload.uploadId), { servicePrerequisite: prerequisite.servicePrerequisite });
36
+ }
31
37
  const reference = resultReference(value, upload, state.evidenceDescriptor, location);
32
38
  if (reference) {
33
39
  state.attestationRef = reference; state.evidenceResult = value; delete state.evidencePending;
@@ -51,22 +57,25 @@ export async function ensureScopeEvidence(state, location, dependencies, challen
51
57
  const upload = evidenceUpload(state.evidencePrepared.upload, descriptor, location, challenge);
52
58
  const token = await dependencies.readCredential(dependencies.home), headers = { authorization: `Bearer ${token}`, ...ideClientHeaders() };
53
59
  const origin = dependencies.apiOrigin();
54
- if (state.evidencePending) {
60
+ const existing = Boolean(state.evidencePending);
61
+ if (existing) {
55
62
  if (state.evidencePending.uploadId !== upload.uploadId || state.evidencePending.sha256 !== descriptor.sha256
56
63
  || state.evidencePending.canonicalBytes !== descriptor.canonicalBytes) evidenceFail('Recover the exact original evidence reservation first');
64
+ }
65
+ if (existing && !await retryKnownPrerequisite(state, location, save, 'evidencePending')) {
57
66
  let response;
58
67
  try { response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers }); }
59
68
  catch { throw unknown(upload.uploadId); }
60
69
  return acceptResult(response, upload, state, location, save);
61
70
  }
62
- state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
63
- await save(location, state);
64
- let response;
71
+ if (!existing) { state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
72
+ await save(location, state); }
73
+ let response; const sentPut = Date.parse(upload.expiresAt) > Date.now();
65
74
  try {
66
- if (Date.parse(upload.expiresAt) <= Date.now()) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
75
+ if (!sentPut) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
67
76
  else response = await putScopeEvidence(origin, upload.uploadId, evidenceBytes(value), headers);
68
77
  } catch { throw unknown(upload.uploadId); }
69
- return acceptResult(response, upload, state, location, save);
78
+ return acceptResult(response, upload, state, location, save, sentPut);
70
79
  }
71
80
  export async function validateAppliedEvidence(record, state, location, challenge, kind) {
72
81
  const reference = evidenceReference(kind === 'scope-context' ? record.attestationRef : record.candidateAttestationRef,
@@ -1,3 +1,4 @@
1
+ import { prerequisiteFields } from './service-prerequisite.mjs';
1
2
  import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
3
  import { constants, promises as fs } from 'node:fs';
3
4
  import path from 'node:path';
@@ -6,10 +7,13 @@ import { scopeFail, scopePath, scopeRoot, scopeTarget } from './local-scope-file
6
7
  import { withStateLock } from './local-state-lock.mjs';
7
8
  import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
8
9
  import { probe } from './local-probe.mjs';
10
+ import { recoveryFailureFields } from './local-recovery-budget.mjs';
9
11
  import { contextRemote } from './scope-context-remote.mjs';
10
- import { scopeRunnerJournal, saveScopeRunnerJournal } from './scope-runner-journal.mjs';
12
+ import { scopeRunnerJournal, saveScopeRunnerJournal, renewScopeRunnerChallenge, validateScopeRunnerChallenge } from './scope-runner-journal.mjs';
11
13
  import { saveLocalScopeEvidence, ensureScopeEvidence } from './scope-evidence-remote.mjs';
12
14
  import { boundScopeContexts } from './scope-context-state.mjs';
15
+ import { mutationFile14 } from './local-mutation-files.mjs';
16
+ import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
13
17
 
14
18
  async function privateDirectory(directory) {
15
19
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -68,8 +72,26 @@ async function scopeConnectionResult(identity, binding, accepted) {
68
72
  runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
69
73
  nextOperation: next.operation, ...(resumeOriginalRequest ? { resumeOriginalRequest: true } : { nextInput }), nextStep: next };
70
74
  }
71
- async function observeConnection(identity, location, state, challenge, contextAuthority, dependencies) {
72
- if (state.evidenceDescriptor) return;
75
+ async function verifyConnectionSnapshot(input) {
76
+ try { return await snapshotVerify(input); }
77
+ catch (error) {
78
+ if (error.code !== 'ENOENT') throw error;
79
+ const directory = path.join(input.home, '.entrorove', 'snapshots', sha256(input.repositoryRoot).slice(0, 32), input.flowId, input.snapshotId);
80
+ const existing = await mutationFile14(directory, { allowMissing: true, final: 'directory' });
81
+ if (!existing.exists) scopeFail('ENTROROVE_SCOPE_SNAPSHOT_MISSING', 'The original scope snapshot directory is unavailable; preserve its journal before another attempt');
82
+ if (!(await mutationFile14(path.join(directory, 'manifest.json'), { allowMissing: true })).exists) {
83
+ scopeFail('ENTROROVE_SCOPE_SNAPSHOT_INTERRUPTED', 'The original scope snapshot was interrupted before its manifest; preserve the partial files and repeat this exact original input to archive its rejected attempt');
84
+ }
85
+ throw error;
86
+ }
87
+ }
88
+ async function observeConnection(identity, location, state, original, contextAuthority, dependencies, remote) {
89
+ let challenge = (state.signingChallenge ?? state.challenged).challenge;
90
+ validateScopeRunnerChallenge(challenge, original, identity, location.binding, { allowExpired: true });
91
+ if (state.evidenceDescriptor) {
92
+ if (state.evidenceDescriptor.challengeId !== challenge.challenge) scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover evidence against its original signing challenge');
93
+ return challenge;
94
+ }
73
95
  const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
74
96
  stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: state.snapshotId, importContexts: identity.importContexts,
75
97
  ...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
@@ -78,13 +100,17 @@ async function observeConnection(identity, location, state, challenge, contextAu
78
100
  try { await snapshotPlan(input); }
79
101
  catch (error) { delete state.snapshotStarted; await saveScopeRunnerJournal(location, state); throw error; }
80
102
  }
81
- const verified = await snapshotVerify(input);
103
+ const verified = await verifyConnectionSnapshot(input);
82
104
  if (verified.verified !== true) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
83
105
  const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
84
106
  chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath: location.signerConfigPath },
85
- { maximumExpiresAt: Date.parse(challenge.expiresAt) });
107
+ { beforeSign: async () => {
108
+ challenge = await renewScopeRunnerChallenge(identity, location, state, original, remote);
109
+ return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
110
+ } });
86
111
  if (observed.attestation.snapshotDigest !== verified.receipt.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual S proof replaced its original verified snapshot');
87
112
  await saveLocalScopeEvidence(observed.attestation, 'scope-observation', challenge, state, location, saveScopeRunnerJournal);
113
+ return challenge;
88
114
  }
89
115
  async function connect(identity, directory, dependencies) {
90
116
  const local = await signer(directory, identity, dependencies), { binding, key, contextAuthority } = local;
@@ -112,8 +138,8 @@ async function connect(identity, directory, dependencies) {
112
138
  }
113
139
  if (!state.snapshotId) { state.snapshotId = `snapshot-${randomUUID()}`; await saveScopeRunnerJournal(location, state); }
114
140
  if (!state.accepted) {
115
- await observeConnection(identity, location, state, challenge, contextAuthority, dependencies);
116
- const attestationRef = await ensureScopeEvidence(state, location, dependencies, challenge, saveScopeRunnerJournal);
141
+ const signingChallenge = await observeConnection(identity, location, state, challenge, contextAuthority, dependencies, remote);
142
+ const attestationRef = await ensureScopeEvidence(state, location, dependencies, signingChallenge, saveScopeRunnerJournal);
117
143
  await remote('accepted', 'scope-runner-attest', { flowId: identity.flowId, attestationRef });
118
144
  }
119
145
  state.completed = true; await saveScopeRunnerJournal(location, state);
@@ -155,11 +181,16 @@ export async function connectScopeRunner(repositoryRoot, input, dependencies) {
155
181
  export async function runScopeConnect(repositoryRoot, input, dependencies) {
156
182
  await dependencies.install();
157
183
  if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
158
- try { return await connectScopeRunner(repositoryRoot, input, dependencies); }
184
+ try {
185
+ await bootstrapCloudMutationAuthority(dependencies);
186
+ return await connectScopeRunner(repositoryRoot, input, dependencies);
187
+ }
159
188
  catch (error) {
160
189
  if (!error.code?.startsWith('ENTROROVE_')) throw error;
161
190
  return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
191
+ ...prerequisiteFields(error),
162
192
  code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}),
193
+ ...recoveryFailureFields(error),
163
194
  ...(error.outcomeUnknown ? { outcomeUnknown: true, nextAction: { method: 'GET', operation: 'scope-connect',
164
195
  ...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
165
196
  }
@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto';
3
3
  import { canonical, sha256 } from './local-integrity.mjs';
4
4
  import { readMutationJson14, atomicMutationJson14, mutationFile14 } from './local-mutation-files.mjs';
5
5
  import { evidenceFail } from './scope-evidence-wire.mjs';
6
+ import { scopeFail } from './local-scope-files.mjs';
7
+ import { consumeRecovery } from './local-recovery-budget.mjs';
6
8
 
7
9
  export async function saveScopeRunnerJournal(location, state) {
8
10
  if (Buffer.byteLength(canonical(state)) + 1 > 8_000_000) evidenceFail('Scope connection journal exceeds its unchanged bounded control limit');
@@ -18,11 +20,19 @@ export async function scopeRunnerJournal(location, identity) {
18
20
  evidenceFail('Scope connection journal replaced its original task, repository or complete input');
19
21
  }
20
22
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
21
- if (state && !state.pending && !state.evidencePending && (state.completed || state.rejection)) {
22
- const archive = path.join(location.directory, `${state.attemptId}-${sha256(canonical(state))}.json`);
23
- try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) evidenceFail('Scope connection history changed'); }
24
- catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
25
- state = undefined;
23
+ if (state && reusableExpiredScopeSnapshot(state, identity)) {
24
+ const archivedDigest = await archiveScopeRunnerAttempt(location, state);
25
+ consumeRecovery(state, 'attempt', state.attemptId);
26
+ state.signingRecovery = { reason: 'known-local-signature-expiry', archivedDigest, snapshotId: state.snapshotId,
27
+ requiresSnapshotVerification: true, grantsExecutionAuthority: false, recoveredAt: new Date().toISOString() };
28
+ delete state.rejection;
29
+ await saveScopeRunnerJournal(location, state);
30
+ } else if (state && !state.pending && !state.evidencePending && (state.completed || state.rejection)) {
31
+ await archiveScopeRunnerAttempt(location, state);
32
+ const recovery = state.completed ? undefined : consumeRecovery(state, 'attempt', state.attemptId);
33
+ state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID(),
34
+ ...(recovery ? { recovery } : {}) };
35
+ await saveScopeRunnerJournal(location, state);
26
36
  }
27
37
  if (!state) {
28
38
  state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID() };
@@ -30,3 +40,70 @@ export async function scopeRunnerJournal(location, identity) {
30
40
  }
31
41
  return state;
32
42
  }
43
+
44
+ export async function archiveScopeRunnerAttempt(location, state) {
45
+ const checksum = sha256(canonical(state));
46
+ const archive = path.join(location.directory, `${state.attemptId}-${checksum}.json`);
47
+ try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) evidenceFail('Scope connection history changed'); }
48
+ catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
49
+ return checksum;
50
+ }
51
+ export function validateScopeRunnerChallenge(value, original, identity, binding, { allowExpired = false } = {}) {
52
+ const immutable = challenge => Object.fromEntries(Object.entries(challenge ?? {}).filter(([field]) => !['challenge', 'expiresAt'].includes(field)));
53
+ const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/, hash = /^[a-f0-9]{64}$/;
54
+ if (!value || !original || canonical(immutable(value)) !== canonical(immutable(original))
55
+ || value.flowId !== identity.flowId || value.runnerId !== binding.runnerId || value.receiverId !== binding.runnerId
56
+ || value.repositoryId !== binding.repositoryId || canonical(value.targetPaths) !== canonical(identity.targetPaths)
57
+ || ['stepId', 'ownerId', 'challenge'].some(field => !id.test(value[field] ?? ''))
58
+ || ['taskDigest', 'chainDigest'].some(field => !hash.test(value[field] ?? ''))
59
+ || !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 0
60
+ || !value.task || typeof value.task !== 'object' || Array.isArray(value.task)) {
61
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Fresh scope challenge replaced its original task, signer, step, paths or revision');
62
+ }
63
+ const expiresAt = Date.parse(value.expiresAt);
64
+ if (!Number.isFinite(expiresAt) || !allowExpired && (expiresAt <= Date.now() || expiresAt > Date.now() + 90_000)) {
65
+ scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Actual scope signature requires its current bounded original challenge');
66
+ }
67
+ return value;
68
+ }
69
+ function noScopeSubmission(state) {
70
+ return !['pending', 'evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'evidenceSignature',
71
+ 'attestation', 'attestationRef', 'evidenceResult', 'accepted'].some(field => Object.hasOwn(state, field));
72
+ }
73
+ function reusableExpiredScopeSnapshot(state, identity) {
74
+ if (!noScopeSubmission(state) || state.completed || state.snapshotStarted !== true
75
+ || typeof state.snapshotId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(state.snapshotId)
76
+ || state.rejection?.code !== 'ENTROROVE_SCOPE_ATTESTATION_BINDING'
77
+ || state.rejection.message !== 'candidate challenge expired before its actual scope signature'
78
+ || state.rejection.requestId || state.rejection.output || state.registered?.registered !== true) return false;
79
+ const enrollment = state.prepared?.enrollment, original = state.challenged?.challenge;
80
+ if (!enrollment || !original || enrollment.flowId !== identity.flowId || state.registered.runnerId !== enrollment.runnerId
81
+ || state.registered.repositoryId !== enrollment.repositoryId
82
+ || canonical(state.registered.targetPaths) !== canonical(identity.targetPaths)
83
+ || ['ownerId', 'taskDigest', 'chainDigest', 'runnerId', 'repositoryId'].some(field => enrollment[field] !== original[field])
84
+ || canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) return false;
85
+ try { validateScopeRunnerChallenge((state.signingChallenge ?? state.challenged).challenge, original, identity, enrollment, { allowExpired: true }); }
86
+ catch { return false; }
87
+ return Date.parse((state.signingChallenge ?? state.challenged).challenge.expiresAt) <= Date.now();
88
+ }
89
+ export async function renewScopeRunnerChallenge(identity, location, state, original, remote) {
90
+ if (!['evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'attestationRef', 'attestation', 'accepted']
91
+ .every(field => !Object.hasOwn(state, field)) || canonical(state.identity) !== canonical(identity)) {
92
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover the original signed evidence before another scope signing challenge');
93
+ }
94
+ if (state.pending && (state.pending.stage !== 'signingChallenge' || !state.signingAttemptId)) {
95
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover only the original pending request before challenge renewal');
96
+ }
97
+ if (!state.pending && state.signingChallenge && Date.parse(state.signingChallenge.challenge?.expiresAt) > Date.now()) {
98
+ return validateScopeRunnerChallenge(state.signingChallenge.challenge, original, identity, location.binding);
99
+ }
100
+ if (!state.pending && (!state.signingAttemptId || state.signingChallenge)) {
101
+ await archiveScopeRunnerAttempt(location, state);
102
+ consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
103
+ delete state.signingChallenge; state.signingAttemptId = randomUUID();
104
+ await saveScopeRunnerJournal(location, state);
105
+ }
106
+ const issued = await remote('signingChallenge', 'scope-runner-challenge', { flowId: identity.flowId,
107
+ receiverId: location.binding.runnerId, attemptId: state.signingAttemptId });
108
+ return validateScopeRunnerChallenge(issued.challenge, original, identity, location.binding);
109
+ }
@@ -0,0 +1,4 @@
1
+ type File = { path: string; bytes: number; lines: number }
2
+ export function projectScopeWriteFacts<T extends { files: File[]; recentChangedLines: number }>(
3
+ facts: T, signedFacts: Record<string, any>, roles: Array<{ path: string; access: 'write' | 'read-only' | 'create' }>
4
+ ): T
@@ -0,0 +1,43 @@
1
+ const version = 'entrorove.scope-write-observation/1.0';
2
+ const fields = ['schemaVersion', 'paths', 'fileCount', 'bytes', 'sourceLines', 'tokenEstimate', 'git', 'estimatedChangedLines'];
3
+ function fail() {
4
+ throw Object.assign(new Error('Write observations require the exact approved roles and independently signed source counts'),
5
+ { code: 'ENTROROVE_SCOPE_WRITE_OBSERVATION' });
6
+ }
7
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
8
+ export function scopeWriteFiles(files, requested) {
9
+ if (!Array.isArray(requested) || !requested.length || requested.length > files.length
10
+ || requested.some(value => typeof value !== 'string') || new Set(requested).size !== requested.length) fail();
11
+ const selected = files.filter(file => requested.includes(file.path));
12
+ if (selected.length !== requested.length) fail();
13
+ return selected;
14
+ }
15
+ export function scopeEstimatedChanges(files, observation, knownFiles) {
16
+ const git = observation.facts;
17
+ return git.available && git.head ? observation.currentDiffPresent ? git.currentChangedLines : git.recentChangedLines
18
+ : files.reduce((total, file) => total + file.lines + (file.exists === false
19
+ ? knownFiles.find(previous => previous.path === file.path)?.lines ?? 0 : 0), 0);
20
+ }
21
+ export function scopeWriteObservation(files, observation, knownFiles) {
22
+ const bytes = files.reduce((total, file) => total + file.bytes, 0);
23
+ return { schemaVersion: version, paths: files.map(file => file.path).sort(), fileCount: files.length, bytes,
24
+ sourceLines: files.reduce((total, file) => total + file.lines, 0), tokenEstimate: Math.ceil(bytes / 4),
25
+ git: observation.facts, estimatedChangedLines: scopeEstimatedChanges(files, observation, knownFiles) };
26
+ }
27
+ // Only call after verifying the original S receipt and its exact approved role set.
28
+ // Older signed receipts retain their conservative full-scope change estimate.
29
+ export function projectScopeWriteFacts(facts, signedFacts, roles) {
30
+ const files = scopeWriteFiles(facts.files, roles.filter(file => file.access !== 'read-only').map(file => file.path));
31
+ const observed = signedFacts.writeScope;
32
+ if (observed === undefined) return { ...facts, files };
33
+ if (!observed || typeof observed !== 'object' || !same(Object.keys(observed).sort(), [...fields].sort())
34
+ || observed.schemaVersion !== version || !same(observed.paths, files.map(file => file.path).sort())
35
+ || !['fileCount', 'bytes', 'sourceLines', 'tokenEstimate', 'estimatedChangedLines']
36
+ .every(field => Number.isSafeInteger(observed[field]) && observed[field] >= 0)
37
+ || observed.fileCount !== files.length || observed.bytes !== files.reduce((sum, file) => sum + file.bytes, 0)
38
+ || observed.sourceLines !== files.reduce((sum, file) => sum + file.lines, 0)
39
+ || observed.tokenEstimate !== Math.ceil(observed.bytes / 4)
40
+ || !observed.git || !signedFacts.git
41
+ || ['available', 'head', 'baseline'].some(field => observed.git[field] !== signedFacts.git[field])) fail();
42
+ return { ...facts, files, recentChangedLines: observed.estimatedChangedLines };
43
+ }
@@ -0,0 +1,87 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+ import { consumeRecovery } from './local-recovery-budget.mjs';
3
+
4
+ const fields = ['schemaVersion', 'error', 'code', 'version', 'requiredSetDigest', 'confirmationUrl',
5
+ 'grantsExecutionAuthority', 'recoverable', 'preAdmission', 'knownBeforeEffects'];
6
+ const bound = 4096;
7
+ export function servicePrerequisite(response, value) {
8
+ if (response?.status !== 403 || !value || typeof value !== 'object' || Array.isArray(value)
9
+ || Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')
10
+ || value.schemaVersion !== 'entrorove.account-agreement-required/1.0'
11
+ || value.error !== 'account-agreement-required' || value.code !== 'ENTROROVE_ACCOUNT_AGREEMENT_REQUIRED'
12
+ || !/^\d{4}-\d{2}-\d{2}\.[1-9][0-9]{0,5}$/.test(value.version ?? '')
13
+ || !/^[a-f0-9]{64}$/.test(value.requiredSetDigest ?? '') || value.confirmationUrl !== '/?legal=confirm'
14
+ || value.grantsExecutionAuthority !== false || value.recoverable !== true
15
+ || typeof value.preAdmission !== 'boolean' || value.preAdmission !== value.knownBeforeEffects
16
+ || Buffer.byteLength(JSON.stringify(value)) > bound) return null;
17
+ return Object.fromEntries(fields.map(field => [field, value[field]]));
18
+ }
19
+ export function prerequisiteError(response, value, binding = {}) {
20
+ const prerequisite = servicePrerequisite(response, value);
21
+ if (!prerequisite) return null;
22
+ return Object.assign(new Error('The account must explicitly confirm the current service agreement on Entrorove before retrying the original request'),
23
+ { code: prerequisite.code, servicePrerequisite: prerequisite, ...binding,
24
+ knownBeforeEffects: binding.outcomeUnknown === true ? false : prerequisite.knownBeforeEffects,
25
+ grantsExecutionAuthority: false, recoverable: true });
26
+ }
27
+ export function prerequisiteFields(error) {
28
+ const value = servicePrerequisite({ status: 403 }, error?.servicePrerequisite);
29
+ return value ? { servicePrerequisite: value, grantsExecutionAuthority: false, recoverable: true,
30
+ interaction: { kind: 'confirm-account-agreement', confirmationUrl: value.confirmationUrl,
31
+ version: value.version, requiredSetDigest: value.requiredSetDigest },
32
+ ...(error.knownBeforeTransmission ? { knownBeforeTransmission: true } : {}),
33
+ ...(error.knownBeforeEffects ? { knownBeforeEffects: true } : {}) } : {};
34
+ }
35
+ export function discoveryPrerequisite(error, requestId) {
36
+ if (!servicePrerequisite({ status: 403 }, error?.servicePrerequisite)) return error;
37
+ Object.assign(error, { sourceRequestId: error.requestId, requestId, knownBeforeTransmission: true });
38
+ delete error.outcomeUnknown; return error;
39
+ }
40
+ export async function retainPrerequisite(error, state, location, save, field = 'pending') {
41
+ const pending = state[field], value = servicePrerequisite({ status: 403 }, error?.servicePrerequisite);
42
+ const identity = pending?.requestId ?? pending?.uploadId;
43
+ if (!pending || !value || !(error.knownBeforeTransmission || error.knownBeforeEffects)
44
+ || (error.requestId ?? error.uploadId) !== identity) return false;
45
+ const { servicePrerequisite: _previous, ...original } = pending;
46
+ const receipt = { originalId: identity, original: structuredClone(original), response: value, httpStatus: 403,
47
+ ...(error.sourceRequestId ? { sourceRequestId: error.sourceRequestId } : {}),
48
+ ...(error.knownBeforeTransmission ? { originalNotSent: true } : {}) };
49
+ pending.servicePrerequisite = receipt;
50
+ state.servicePrerequisiteHistory = [...(state.servicePrerequisiteHistory ?? []), receipt];
51
+ await save(location, state); return true;
52
+ }
53
+ export async function retryKnownPrerequisite(state, location, save, field = 'pending') {
54
+ const pending = state[field], receipt = pending?.servicePrerequisite;
55
+ if (!receipt) return false;
56
+ const value = servicePrerequisite({ status: receipt.httpStatus }, receipt.response);
57
+ const { servicePrerequisite: _receipt, ...original } = pending;
58
+ if (!value || receipt.originalId !== (pending.requestId ?? pending.uploadId)
59
+ || canonical(receipt.original) !== canonical(original)
60
+ || !(receipt.originalNotSent === true || value.preAdmission === true && value.knownBeforeEffects === true)) {
61
+ throw new Error('Original service prerequisite receipt is invalid; preserve its journal without retransmission');
62
+ }
63
+ // Persist a new unresolved attempt before sending. If that attempt loses its
64
+ // response, later calls must use GET even though an earlier attempt was denied.
65
+ consumeRecovery(state, 'prerequisite');
66
+ delete pending.servicePrerequisite; await save(location, state); return true;
67
+ }
68
+ export async function fetchPrerequisiteError(response, binding = {}) {
69
+ if (response.status !== 403 || !response.headers.get('content-type')?.startsWith('application/json')) return null;
70
+ const reader = response.body?.getReader(); if (!reader) return null;
71
+ const chunks = []; let size = 0;
72
+ try {
73
+ while (true) {
74
+ const { done, value } = await reader.read(); if (done) break;
75
+ size += value.byteLength; if (size > bound) return null; chunks.push(Buffer.from(value));
76
+ }
77
+ return prerequisiteError(response, JSON.parse(Buffer.concat(chunks).toString('utf8')), binding);
78
+ } catch { return null; } finally { await reader.cancel().catch(() => {}); }
79
+ }
80
+ export function streamPrerequisiteError(value, binding = {}) {
81
+ if (!['ENTROROVE_ACCOUNT_AGREEMENT_REQUIRED', 'account-agreement-required'].includes(value?.code)
82
+ || value.recoverable !== true || value.grantsExecutionAuthority !== false) return null;
83
+ const prerequisite = servicePrerequisite({ status: 403 }, value.prerequisite);
84
+ if (!prerequisite || prerequisite.preAdmission || prerequisite.knownBeforeEffects) return null;
85
+ // This is a running stream notification, not a proof about prior effects.
86
+ return prerequisiteError({ status: 403 }, prerequisite, { ...binding, outcomeUnknown: true });
87
+ }
@@ -1,6 +1,7 @@
1
1
  import { requestTransport, parseTransportResponse, trustedOrigin } from './remote-transport.mjs';
2
2
  import { runCloudTask } from './cloud-task-client.mjs';
3
3
  import { displayIdeEvent, ideClientHeaders, ideTaskIdentity } from './ide-presentation.mjs';
4
+ import { prerequisiteError } from './service-prerequisite.mjs';
4
5
 
5
6
  /** New IDE tasks use the authenticated account preference; resumes keep their original route. */
6
7
  export async function routeTaskEntry(data, token, dependencies) {
@@ -20,6 +21,8 @@ export async function routeTaskEntry(data, token, dependencies) {
20
21
  headers: { authorization: `Bearer ${token}`,...ideClientHeaders() },timeoutMs: 15000,
21
22
  });
22
23
  const preference = parseTransportResponse(response, 'cloud-sandbox preference');
24
+ const prerequisite = prerequisiteError(response, preference, { knownBeforeTransmission: true });
25
+ if (prerequisite) throw prerequisite;
23
26
  if (!response.ok || typeof preference?.enabled !== 'boolean') {
24
27
  throw new Error(`Cloud sandbox preference unavailable: HTTP ${response.status}. Task entry stopped.`);
25
28
  }
@@ -1,5 +1,7 @@
1
+ import { prerequisiteFields } from './service-prerequisite.mjs';
1
2
  import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
2
3
  import { observationInput } from './workflow-observation-wire.mjs';
4
+ import { recoveryFailureFields } from './local-recovery-budget.mjs';
3
5
 
4
6
  const safeId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
5
7
  const safeRequest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
@@ -31,6 +33,7 @@ function blocked(error, input) {
31
33
  && Number.isSafeInteger(cause.rejection.generation) && cause.rejection.generation > 0
32
34
  && ['member-observation-admit', 'member-observation-report'].includes(cause.rejection.operation);
33
35
  return { schemaVersion: 'entrorove.local-observation-connection/1.0', status: 'blocked',
36
+ ...prerequisiteFields(cause), ...recoveryFailureFields(cause),
34
37
  ...(safeId(input?.flowId) ? { flowId: input.flowId } : {}), independentValidation: 'pending', code,
35
38
  message: messages[code] ?? 'Local observation could not continue; its original command and result remain preserved',
36
39
  ...(recoverable ? { prerequisite: { status: 'blocked', code, recoverable: true, ...(reason ? { reason } : {}),
@@ -7,13 +7,16 @@ import { runWorkflowObservation } from './workflow-observation-runner.mjs';
7
7
  import { observationRemote, authorizeObservation } from './workflow-observation-remote.mjs';
8
8
  import { verifyWorkflowLocalAcceptance } from './workflow-local-acceptance.mjs';
9
9
  import { buildInvocation } from './remote-client.mjs';
10
+ import { recoverInterruptedObservation } from './workflow-observation-recovery.mjs';
10
11
 
11
12
  async function connect(input, state, location, dependencies) {
12
13
  const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', ...(Object.hasOwn(input, 'settlementId') ? ['settlementId'] : []),
13
14
  ...(Object.hasOwn(input, 'dependencyRepairDigest') ? ['dependencyRepairDigest'] : [])];
14
15
  const original = Object.fromEntries(fields.map(key => [key, input[key]]));
15
16
  const admitted = state.admitted ?? await observationRemote('admitted', 'member-observation-admit', original, state, location, dependencies);
16
- const admission = observationAdmission(admitted.admission, input, { allowExpired: Boolean(state.result) });
17
+ const recovering = !state.result && (Boolean(state.run) || Date.parse(admitted.admission?.expiresAt) <= Date.now());
18
+ const admission = observationAdmission(admitted.admission, input, { allowExpired: Boolean(state.result) || recovering });
19
+ if (recovering) return recoverInterruptedObservation(original, admission, state, location, dependencies);
17
20
  await authorizeObservation(admission, dependencies, !state.result);
18
21
  const cached = Boolean(state.result), result = await runWorkflowObservation(input, admission, state, location,
19
22
  { authorize: () => authorizeObservation(admission, dependencies, true) });
@@ -0,0 +1,12 @@
1
+ import { sha256 } from './local-integrity.mjs';
2
+
3
+ // This evidence is created only inside the live launch boundary. An old journal
4
+ // saying "aborted", or a missing PID, cannot be promoted into this proof.
5
+ export function notStartedProcess(error, beforeLaunch) {
6
+ if (!beforeLaunch && (error?.execution?.effects !== 'none' || error.execution.prelaunchVerified !== true)) return null;
7
+ const now = new Date().toISOString();
8
+ return { status: 'failed', started: false, startedAt: null, finishedAt: now,
9
+ exitCode: null, signal: null, timedOut: false, outputLimitExceeded: false,
10
+ durationMs: 0, stdoutSha256: sha256(''), stderrSha256: sha256(''), capturedBytes: 0, droppedBytes: 0,
11
+ notStartedReason: beforeLaunch ? 'prelaunch-check' : 'supervisor-prelaunch' };
12
+ }
@@ -0,0 +1,42 @@
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
+ import { buildInvocation } from './remote-client.mjs';
3
+ import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
4
+ import { ideClientHeaders } from './ide-presentation.mjs';
5
+ import { consumeRecovery } from './local-recovery-budget.mjs';
6
+ import { saveObservationState } from './workflow-observation-state.mjs';
7
+ import { authorizeObservation } from './workflow-observation-remote.mjs';
8
+ import { observationFail } from './workflow-observation-wire.mjs';
9
+
10
+ export async function recoverInterruptedObservation(original, admission, state, location, dependencies) {
11
+ // A missing result, expired lease or absent PID is never evidence of no effects.
12
+ consumeRecovery(state, 'status');
13
+ await saveObservationState(location, state);
14
+ await authorizeObservation(admission, dependencies, false);
15
+ const requestId = buildInvocation('member-observation-admit', original).requestId;
16
+ if (state.admitted.requestId && state.admitted.requestId !== requestId) observationFail('Interrupted observation lost its original request binding');
17
+ const url = `/api/skill/status/${requestId}`, token = await dependencies.readCredential(dependencies.home);
18
+ const response = await requestTransport(dependencies.apiOrigin(), url, { headers: {
19
+ authorization: `Bearer ${token}`, ...ideClientHeaders() } });
20
+ const result = parseTransportResponse(response, 'original interrupted observation admission');
21
+ if (!response.ok || result?.output?.requestId !== requestId || !result.output.admission || !['reported', 'succeeded'].includes(result.output.status)
22
+ || sha256(canonical(result.output.admission)) !== sha256(canonical(admission))) {
23
+ throw Object.assign(new Error('Original interrupted observation admission remains unavailable; never replay its POST or process'),
24
+ { code: 'ENTROROVE_LOCAL_OBSERVATION_UNKNOWN', outcomeUnknown: true, requestId });
25
+ }
26
+ state.interruptedRecovery = { schemaVersion: 'entrorove.observation-recovery/1.0', requestId,
27
+ observationId: admission.observationId, admissionDigest: sha256(canonical(admission)), method: 'GET',
28
+ checkedAt: new Date().toISOString(), processResultAvailable: false, effects: 'unknown' };
29
+ await saveObservationState(location, state);
30
+ return { schemaVersion: 'entrorove.local-observation-connection/1.0', status: 'blocked',
31
+ code: 'ENTROROVE_LOCAL_OBSERVATION_RECOVERY_REQUIRED', flowId: admission.flowId,
32
+ observationId: admission.observationId, requestId, independentValidation: 'pending',
33
+ grantsExecutionAuthority: false, grantsMutationAuthority: false, grantsValidatorAuthority: false,
34
+ localProcessMayRestart: false, repeatPost: false, effects: 'unknown', automaticRetryAllowed: false,
35
+ recovery: { method: 'GET', url, originalAdmissionVerified: true, processResultAvailable: false,
36
+ budget: structuredClone(state.recovery) },
37
+ ownerDecision: { required: true, automatic: false, operation: 'flow-cancel',
38
+ input: { flowId: admission.flowId, reason: 'Original observation interrupted without a recoverable process result; effects remain unknown' },
39
+ grantsExecutionAuthority: false, grantsMutationAuthority: false,
40
+ instruction: 'Reconcile the original external effects or ask the original owner to cancel this blocked flow while preserving its unknown history. Cancellation is not acceptance, restoration or permission to rerun.' },
41
+ message: 'The original admission was recovered read-only. Its process result is unavailable; preserve the unknown effects and obtain an explicit owner disposition, without repeating the command.' };
42
+ }
@@ -6,6 +6,7 @@ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFe
6
6
  import { saveObservationState } from './workflow-observation-state.mjs';
7
7
  import { observationFail } from './workflow-observation-wire.mjs';
8
8
  import { readInvocations } from './local-invocations.mjs';
9
+ import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
9
10
 
10
11
  const unknown = requestId => Object.assign(new Error('Original local observation request outcome is unknown; recover its GET without repeating execution'),
11
12
  { code: 'ENTROROVE_LOCAL_OBSERVATION_UNKNOWN', outcomeUnknown: true, requestId });
@@ -25,12 +26,15 @@ async function recover(pending, dependencies) {
25
26
  response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, { headers: {
26
27
  authorization: `Bearer ${token}`, ...ideClientHeaders() } });
27
28
  result = parseTransportResponse(response, 'original local observation status');
29
+ const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
30
+ if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
28
31
  if (result?.output?.requestId !== pending.requestId || !response.ok && response.status >= 500
29
32
  || ['pending', 'running'].includes(result.output.status)) throw unknown(pending.requestId);
30
33
  result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
31
34
  verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
32
35
  result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
33
- } catch { throw unknown(pending.requestId); }
36
+ } catch (error) { throw Object.assign(unknown(pending.requestId),
37
+ error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
34
38
  return { response, result };
35
39
  }
36
40
  function prerequisite(output) {
@@ -66,21 +70,25 @@ function terminalRejection(result, pending) {
66
70
  export async function observationRemote(stage, operation, input, state, location, dependencies) {
67
71
  const requestId = buildInvocation(operation, input).requestId;
68
72
  let response, result;
69
- if (state.pending) {
73
+ const existing = Boolean(state.pending);
74
+ if (existing) {
70
75
  if (state.pending.stage !== stage || state.pending.operation !== operation || state.pending.requestId !== requestId
71
76
  || canonical(state.pending.input) !== canonical(input)) observationFail('Original observation request must finish before another operation');
77
+ }
78
+ if (existing && !await retryKnownPrerequisite(state, location, saveObservationState)) {
72
79
  ({ response, result } = await recover(state.pending, dependencies));
73
80
  } else {
74
- state.pending = { stage, operation, input, requestId };
81
+ if (!existing) state.pending = { stage, operation, input, requestId };
75
82
  try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
76
83
  try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
77
- catch {
84
+ catch (error) {
85
+ if (await retainPrerequisite(error, state, location, saveObservationState)) throw error;
78
86
  if (await knownNotSent(state.pending, dependencies.home)) {
79
87
  delete state.pending;
80
88
  try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
81
89
  throw notSent(requestId);
82
90
  }
83
- throw unknown(requestId);
91
+ throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
84
92
  }
85
93
  }
86
94
  const statuses = ['reported', 'succeeded', ...(operation === 'member-observation-report' ? ['local-accepted'] : [])];
@@ -7,6 +7,7 @@ import { disciplineRepositoryObservation, disciplineRepositoryDifferences } from
7
7
  import { runApprovedProcess } from './local-approved-process.mjs';
8
8
  import { observationAdmission, observationFail } from './workflow-observation-wire.mjs';
9
9
  import { saveObservationState } from './workflow-observation-state.mjs';
10
+ import { notStartedProcess } from './workflow-observation-interruption.mjs';
10
11
 
11
12
  async function observeFile(input, expected) {
12
13
  const before = await scopeTarget(input.repositoryRoot, expected.path, false, true);
@@ -32,8 +33,9 @@ async function executable(admission) {
32
33
  const same = (before, after) => canonical(before) === canonical(after);
33
34
  async function remains(action, expected) { try { return same(expected, await action()); } catch { return false; } }
34
35
  function processMetadata(process) {
35
- return Object.fromEntries(['status', 'started', 'startedAt', 'finishedAt', 'exitCode', 'signal', 'timedOut',
36
- 'outputLimitExceeded', 'durationMs', 'stdoutSha256', 'stderrSha256', 'capturedBytes', 'droppedBytes'].map(key => [key, process[key]]));
36
+ return { ...Object.fromEntries(['status', 'started', 'startedAt', 'finishedAt', 'exitCode', 'signal', 'timedOut',
37
+ 'outputLimitExceeded', 'durationMs', 'stdoutSha256', 'stderrSha256', 'capturedBytes', 'droppedBytes'].map(key => [key, process[key]])),
38
+ ...(process.notStartedReason ? { notStartedReason: process.notStartedReason } : {}) };
37
39
  }
38
40
  async function execute(input, admission, state, location, options) {
39
41
  if (state.run) observationFail('Original local observation already started or has an unknown outcome; a new process is forbidden');
@@ -45,7 +47,7 @@ async function execute(input, admission, state, location, options) {
45
47
  observationAdmission(admission, input);
46
48
  state.run = { phase: 'running', pid: process.pid, startedAt: new Date().toISOString() };
47
49
  await saveObservationState(location, state);
48
- let processResult;
50
+ let processResult, beforeLaunch = true;
49
51
  try {
50
52
  await assertBudgetRunnerAllowed(input);
51
53
  if (!same(originalSources, await files(input, admission.sourceManifest))
@@ -54,14 +56,19 @@ async function execute(input, admission, state, location, options) {
54
56
  observationAdmission(admission, input);
55
57
  if (options.authorize) await options.authorize();
56
58
  observationAdmission(admission, input);
59
+ beforeLaunch = false;
57
60
  processResult = await runApprovedProcess({ root: input.repositoryRoot, policy: admission.command,
58
61
  objectPlan: true, startDeadlineAt: admission.expiresAt });
59
62
  } catch (error) {
60
63
  if (!error?.execution?.process) {
61
- state.run.phase = 'aborted'; await saveObservationState(location, state);
62
- observationFail('Local observation stopped before a completed process; recover the original observation without rerunning');
64
+ processResult = notStartedProcess(error, beforeLaunch);
65
+ if (!processResult) {
66
+ state.run.phase = 'aborted'; await saveObservationState(location, state);
67
+ observationFail('Local observation stopped without a completed process; its effects remain unknown and execution cannot repeat');
68
+ }
69
+ } else {
70
+ processResult = error.execution.process;
63
71
  }
64
- processResult = error.execution.process;
65
72
  }
66
73
  let after = before, budgetAllowed = true;
67
74
  try { await assertBudgetRunnerAllowed(input); after = (await disciplineRepositoryObservation(input, input.repositoryRoot)).comparison; }