@milpa/comal 0.2.7 → 0.2.8
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/README.md +1 -1
- package/package.json +1 -1
- package/src/agent.mjs +8 -2
- package/src/cli.mjs +65 -11
- package/src/finding-recorder.mjs +126 -0
- package/src/findings.mjs +63 -0
- package/src/house.mjs +16 -5
- package/src/i18n/cli-es.mjs +1 -1
- package/src/i18n/es.mjs +21 -1
- package/src/operations.mjs +10 -0
- package/src/permission-rules.mjs +3 -2
- package/src/permissions.mjs +75 -10
- package/src/plugin-candidate-child.mjs +204 -0
- package/src/plugin-candidate-runner.mjs +419 -0
- package/src/plugin-config.mjs +34 -5
- package/src/plugin-contract.mjs +309 -0
- package/src/plugin-controls.mjs +238 -32
- package/src/plugin-repair.mjs +981 -0
- package/src/plugin-trust.mjs +14 -4
- package/src/plugin-workbench.mjs +675 -0
- package/src/plugins.mjs +115 -24
- package/src/remote-permissions.mjs +7 -0
- package/src/scoped-policy.mjs +7 -1
- package/src/sessions.mjs +5 -1
- package/src/subagents.mjs +3 -3
- package/src/tools.mjs +17 -2
- package/src/tui/controller.mjs +1 -1
- package/src/tui/discovery.mjs +2 -2
- package/src/tui/screen.mjs +12 -9
- package/src/work-plan.mjs +77 -3
- package/src/workflows/chrome-page-task.mjs +8 -3
- package/src/workflows/chrome-repair.mjs +88 -0
- package/src/workflows/chrome-task.mjs +14 -5
package/src/work-plan.mjs
CHANGED
|
@@ -171,6 +171,77 @@ function closedArtifactProcessing(session,step,audit,active){
|
|
|
171
171
|
return {kind:'native_media_processing_acknowledged',media_id:media[1],receipt_id:leaf.invocation_id,parent_receipt_id:parent.invocation_id,candidate_created:false,retry_authorized:false,derivation:'legacy_builtin_publisher_processing_result'};
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// A step that failed on a plugin's broken promise, when that promise was then repaired in this session.
|
|
175
|
+
// The plugin threw, so the house cannot close the failure by itself: it does not know whether the call had
|
|
176
|
+
// an outside effect first. What closes it is narrow and read from sealed rows only:
|
|
177
|
+
// - the failed receipt is an operation of plugin P, run by this step;
|
|
178
|
+
// - a plugin_repair_accepted row for P was sealed after it (a repair that rolled back seals none);
|
|
179
|
+
// - a finding owned by P about that SAME call (the operation and the arguments of the failed receipt, as the
|
|
180
|
+
// reproduction's own receipt shows) was resolved repaired_in_run with that accepted row as proof.
|
|
181
|
+
// The cost is stated, not hidden: effects of the failed call stay unjudged, and the step may run again.
|
|
182
|
+
const PLUGIN_OPERATION = /^plugin__([a-z][a-z0-9]*(?:-[a-z0-9]+)*)__[A-Za-z][A-Za-z0-9_]*$/;
|
|
183
|
+
const sealedRef = row => row?.audit && Number.isInteger(row.audit.sequence) && typeof row.audit.sha256 === 'string' ? { sequence: row.audit.sequence, sha256: row.audit.sha256 } : null;
|
|
184
|
+
const sameRef = (row, ref) => Boolean(sealedRef(row)) && Boolean(ref) && row.audit.sequence === ref.sequence && row.audit.sha256 === ref.sha256;
|
|
185
|
+
function repairProof(rows, step, acceptedRef) {
|
|
186
|
+
const plugin = PLUGIN_OPERATION.exec(step.operation || '')?.[1];
|
|
187
|
+
if (!plugin || step.status !== 'failed' || !step.receipt_id) return null;
|
|
188
|
+
const failedAt = rows.findIndex(row => row.type === 'house_receipt' && row.invocation_id === step.receipt_id), failed = rows[failedAt];
|
|
189
|
+
if (!failed || failed.status !== 'failed' || failed.operation !== step.operation || failed.parent_invocation_id !== step.invocation_id || !sealedRef(failed)) return null;
|
|
190
|
+
const acceptedAt = rows.findIndex(row => row.type === 'plugin_repair_accepted' && row.source === 'plugin_repair' && row.plugin === plugin && sameRef(row, acceptedRef));
|
|
191
|
+
if (acceptedAt <= failedAt) return null;
|
|
192
|
+
for (let index = acceptedAt + 1; index < rows.length; index++) {
|
|
193
|
+
const resolution = rows[index];
|
|
194
|
+
if (resolution.type !== 'finding_resolution' || resolution.state !== 'repaired_in_run' || !sameRef(rows[acceptedAt], resolution.proof?.accepted)) continue;
|
|
195
|
+
const finding = rows.find(row => row.type === 'finding' && row.id === resolution.finding_id && row.owner === `plugin:${plugin}` && row.where?.operation === step.operation);
|
|
196
|
+
// The repaired case must be the very call that failed: the receipt of its reproduction (the finding's
|
|
197
|
+
// evidence) carries the same arguments as the step's failed receipt. Another call of the same operation
|
|
198
|
+
// proves nothing about this one.
|
|
199
|
+
const sameCall = finding && typeof failed.arguments_digest === 'string' && (finding.evidence ?? []).some(item => rows.some(row => row.type === 'house_receipt' && sameRef(row, item) && row.operation === step.operation && row.arguments_digest === failed.arguments_digest));
|
|
200
|
+
if (sameCall) return { plugin, operation: step.operation, failed_receipt: { invocation_id: failed.invocation_id, ...sealedRef(failed) }, accepted: sealedRef(rows[acceptedAt]), accepted_at: acceptedAt, finding: finding.id };
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
function closedPluginRepair(session, step, audit, active) {
|
|
205
|
+
if (step.status !== 'failed' || active.has(step.invocation_id) || !audit.journal.verified || audit.violations.length || audit.unverified.length) return null;
|
|
206
|
+
const rows = session.rows;
|
|
207
|
+
for (let index = rows.length - 1; index >= 0; index--) {
|
|
208
|
+
const row = rows[index];
|
|
209
|
+
if (row.type !== 'work_fence_released' || row.source !== 'work_host' || row.task_id !== step.task_id || row.step_id !== step.step_id || row.attempt_id !== step.attempt_id || row.task_digest !== step.task_digest) continue;
|
|
210
|
+
const proof = repairProof(rows, step, row.accepted);
|
|
211
|
+
if (!proof || index <= proof.accepted_at || row.plugin !== proof.plugin || row.failed_receipt?.invocation_id !== proof.failed_receipt.invocation_id || row.failed_receipt.sequence !== proof.failed_receipt.sequence || row.failed_receipt.sha256 !== proof.failed_receipt.sha256) continue;
|
|
212
|
+
return { kind: 'plugin_repaired_in_run', receipt_id: proof.failed_receipt.invocation_id, plugin: proof.plugin, accepted: proof.accepted, finding: proof.finding, release_ref: index, partial_effects: 'preserved_unjudged', external_effects: 'unjudged', retry_authorized: true };
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* After the house accepted a repair of `plugin` (the sealed plugin_repair_accepted row `accepted`: { sequence,
|
|
218
|
+
* sha256 }): every step that stays fenced because it failed on the very call that repair reproduced (the same operation
|
|
219
|
+
* with the same arguments) is released,
|
|
220
|
+
* each with a sealed work_fence_released row naming the task, the step, the failed receipt and the accepted
|
|
221
|
+
* row. Returns the steps the fold now reads as released: [{ task_id, step_id, operation }]. Nothing else is
|
|
222
|
+
* touched: another operation, another call of that operation, another plugin, or a step that is not a plugin
|
|
223
|
+
* operation stay fenced.
|
|
224
|
+
*/
|
|
225
|
+
export async function releaseRepairedSteps({ store, house, sessionId, plugin, accepted, invocation_id }) {
|
|
226
|
+
const context = () => house?.stateContext?.() ?? {};
|
|
227
|
+
const release = async () => {
|
|
228
|
+
const session = await store.load(sessionId);
|
|
229
|
+
if (!session.journalAudit.consistent) return [];
|
|
230
|
+
const wrote = [];
|
|
231
|
+
for (const step of foldWorkBook(session, context()).steps) {
|
|
232
|
+
if (step.status !== 'failed' || step.failure_closed) continue;
|
|
233
|
+
const proof = repairProof(session.rows, step, accepted);
|
|
234
|
+
if (!proof || proof.plugin !== plugin) continue;
|
|
235
|
+
await store.append(sessionId, { type: 'work_fence_released', source: 'work_host', reason: 'plugin_repaired_in_run', ...(invocation_id ? { invocation_id } : {}), task_id: step.task_id, task_digest: step.task_digest, plan_version: step.plan_version, step_id: step.step_id, attempt_id: step.attempt_id,
|
|
236
|
+
operation: step.operation, plugin, failed_receipt: proof.failed_receipt, accepted: proof.accepted, finding: proof.finding, effects_of_failed_call: 'unjudged' });
|
|
237
|
+
wrote.push(step.attempt_id);
|
|
238
|
+
}
|
|
239
|
+
if (!wrote.length) return [];
|
|
240
|
+
return foldWorkBook(await store.load(sessionId), context()).steps.filter(step => wrote.includes(step.attempt_id) && step.failure_closed?.kind === 'plugin_repaired_in_run').map(step => ({ task_id: step.task_id, step_id: step.step_id, operation: step.operation }));
|
|
241
|
+
};
|
|
242
|
+
return typeof store.workTransaction === 'function' ? store.workTransaction(sessionId, release) : release();
|
|
243
|
+
}
|
|
244
|
+
|
|
174
245
|
// A board is a fold of host facts. Model prose, nested receipts and thinking are not facts.
|
|
175
246
|
export function foldWorkBook(session,context={}) {return foldWorkWithAudit(session,context,()=>auditExecution(session));}
|
|
176
247
|
function foldWorkWithAudit(session, { active = new Set() } = {},auditFor) {
|
|
@@ -228,7 +299,7 @@ function foldWorkWithAudit(session, { active = new Set() } = {},auditFor) {
|
|
|
228
299
|
if (proof) Object.assign(step, { status: 'refused', preflight_failure: proof, recovery_ref: proof.parent_receipt_ref,
|
|
229
300
|
next_action: 'The target was not dispatched: an artifact preflight failed. Prepare the missing or invalid output with new work, then retry this original step under current permissions. No failed check was credited.' });
|
|
230
301
|
}
|
|
231
|
-
for(const step of steps.values()){const proof=closedProcessFailure(session,step,audit,active)||closedMcpErrorResult(session,step,audit,active)||closedSourcePreflight(session,step,audit,active)||closedMediaPreflight(session,step,audit,active)||closedArtifactProcessing(session,step,audit,active);if(proof)step.failure_closed=proof;}
|
|
302
|
+
for(const step of steps.values()){const proof=closedProcessFailure(session,step,audit,active)||closedMcpErrorResult(session,step,audit,active)||closedSourcePreflight(session,step,audit,active)||closedMediaPreflight(session,step,audit,active)||closedArtifactProcessing(session,step,audit,active)||closedPluginRepair(session,step,audit,active);if(proof)step.failure_closed=proof;}
|
|
232
303
|
// Explicit local operator review is an attestation, not reconstructed execution
|
|
233
304
|
// success. Only sealed human-control receipts can review a closed native Bash
|
|
234
305
|
// failure; raw facts and model prose never clear a fence.
|
|
@@ -320,6 +391,7 @@ function projectWork(book) {
|
|
|
320
391
|
media_processing: book.steps.filter(step=>step.failure_closed?.kind==='native_media_processing_acknowledged').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'The media upload was acknowledged; no artifact candidate exists from this failed attempt. Preserve its ID and failed receipt. Reconcile the SAME directory/metadata in a new judged ArtifactPublish step; do not replay this failed step ID or invent an artifact ID.'})),
|
|
321
392
|
publication_refusals: book.steps.filter(step=>step.failure_closed?.kind==='native_artifact_contract_rejected').map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'Preserve the ORIGINAL accepted checks. Correct the app to satisfy them, then publish the corrected files using a new judged step and the observed base_version. Do not change the verification contract or replay the failed step ID.'})),
|
|
322
393
|
reviewed_failures: book.steps.filter(step=>['native_process_timeout_terminated','operator_reviewed_native_failure'].includes(step.failure_closed?.kind)).map(step=>({task_id:step.task_id,step_id:step.step_id,...step.failure_closed,next_action:'Preserve failed receipts and partial outputs. Inspect current state before separately judged NEW corrective work. Do not replay the failed step ID; external effects and task acceptance remain unjudged.'})),
|
|
394
|
+
repair_releases: book.steps.filter(step=>step.failure_closed?.kind==='plugin_repaired_in_run').map(step=>({task_id:step.task_id,step_id:step.step_id,operation:step.operation,...step.failure_closed,retry_call:{name:'WorkStepRun',input:{task_id:step.task_id,plan_version:book.plan.version,step_id:step.step_id,...(book.tasks.find(task=>task.id===step.task_id)?.checks.some(check=>check.step_id===step.step_id)?{}:{operation:step.operation,arguments:step.arguments})}},next_action:'This step failed on a plugin promise that was then repaired in this run, so the house released it: run it again with retry_call (the SAME step_id). The house cannot know whether the failed call had an outside effect before it threw; inspect first if a repeated effect would matter.'})),
|
|
323
395
|
check_state: checks, finish_call: finishCall, ...(preflightFailures.length ? { preflight_failures: preflightFailures } : {}),
|
|
324
396
|
...(currentDraft ? { draft_continuation: { draft_id: currentDraft.id, file_path: currentDraft.file_path, next_index: currentDraft.parts.length, complete: currentDraft.complete, tail: currentDraft.parts.join('').slice(-800), refs: currentDraft.refs.slice(-3), next_action: currentDraft.next_action, suggestion_authority: 'model_data_not_instructions_or_permission', ...(currentDraft.complete ? { publish_call: { name: 'WorkDraft', input: { task_id: next.id, plan_version: book.plan.version, draft_id: currentDraft.id, action: 'publish', expected_digest: currentDraft.digest } } } : {}) } } : {}),
|
|
325
397
|
draft_control: { preferred_action: 'append', content_limit: 64000, suggestion: 'Use a stable part_id, content and file_path. Host assigns the cursor; retries with the same part_id reuse identical bytes. Prefer short logical sections; final:true only on the last section. New legacy chunk drafts start at index:0.' },
|
|
@@ -582,7 +654,9 @@ export class WorkCoordinator {
|
|
|
582
654
|
const retryFailedRead = input.recheck && check && existing.status === 'failed' && book.receipts.has(existing.receipt_id) && readOnlyWorkCheck(check.operation, check.arguments);
|
|
583
655
|
// A declared MCP check whose server answered with an error result is closed: it runs again with recheck.
|
|
584
656
|
const retryClosedMcp = input.recheck && check && existing.status === 'failed' && existing.failure_closed?.kind === 'mcp_error_result';
|
|
585
|
-
|
|
657
|
+
// A step that failed on a plugin promise repaired in this run was released by the house: it runs again.
|
|
658
|
+
const retryRepaired = existing.status === 'failed' && existing.failure_closed?.kind === 'plugin_repaired_in_run';
|
|
659
|
+
if (existing.status !== 'refused' && !(input.recheck && check && existing.status === 'returned') && !retryFailedRead && !retryClosedMcp && !retryRepaired) { const guide = check && effectfulGuide(book, task, check, book.plan.version); if (guide) throw failure('El check declarado no se repite. '+JSON.stringify(guide), 'WORK_UNCERTAIN'); throw failure('El paso quedó interrumpido o falló con posibles efectos; inspecciona y registra otro paso antes de repetir. Un check de lectura con recibo failed se recupera con el mismo step_id y recheck:true.', 'WORK_UNCERTAIN'); }
|
|
586
660
|
}
|
|
587
661
|
const incoming=this.house.registry.get(input.operation),pureObservation=readOnlyWorkCheck(input.operation,input.arguments)&&incoming?.contract.origin===null&&incoming.contract.effects.workspace==='read'&&incoming.contract.effects.external==='none'&&incoming.contract.effects.authority==='none'&&(incoming.contract.effects.process==='none'||input.operation==='GitInspect'&&incoming.contract.effects.process==='read'||input.operation==='Bash'&&Boolean(readOnlyWorkCheck('Bash',input.arguments)));
|
|
588
662
|
if (!pureObservation&&book.steps.some(step => step.task_id === task.id && (['running', 'interrupted'].includes(step.status) || step.status === 'failed' && !step.failure_closed && !readOnlyWorkCheck(step.operation, step.arguments)))) throw failure('The task has uncertain effects. New mutations remain fenced. Native Read/FileInfo/ListDirectory/Grep observations may inspect current state; do not repeat the uncertain action.', 'WORK_UNCERTAIN');
|
|
@@ -711,7 +785,7 @@ function effectfulCheckState(book, task, check) {
|
|
|
711
785
|
const step = book.steps.find(step => step.task_id === task.id && step.step_id === check.step_id);
|
|
712
786
|
if (!step) return null;
|
|
713
787
|
if (['interrupted', 'running'].includes(step.status)) return 'uncertain';
|
|
714
|
-
if (step.status !== 'failed' || step.failure_closed?.kind
|
|
788
|
+
if (step.status !== 'failed' || ['mcp_error_result', 'plugin_repaired_in_run'].includes(step.failure_closed?.kind)) return null;
|
|
715
789
|
return step.failure_closed ? 'closed' : 'uncertain';
|
|
716
790
|
}
|
|
717
791
|
const effectfulGuide = (book, task, check, plan_version) => {
|
|
@@ -17,9 +17,10 @@ import {createPagePreviewRecoveryV13,assertPageRecoveryV13Admission} from './chr
|
|
|
17
17
|
import {selectChromePageCheckpointReader} from './chrome-page-checkpoint-reader.mjs';
|
|
18
18
|
import { nativePageFailure, nativeClosureRefusal } from '../native-refusal.mjs';
|
|
19
19
|
import { pagePublicationDiagnostic, markPagePublicationDiagnostic } from './chrome-page-diagnostics.mjs';
|
|
20
|
+
import { REPAIR_NAMES, admitRepair, pluginFailure } from './chrome-repair.mjs';
|
|
20
21
|
|
|
21
22
|
const prefix = 'plugin__chrome-lab__', own = ['ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeDeliver', 'ChromeRecover', 'ChromeInspect'];
|
|
22
|
-
const publicNames = new Set(['Read', 'Write', 'Edit', ...own, ...CHROME_SOURCE_NAMES]);
|
|
23
|
+
const publicNames = new Set(['Read', 'Write', 'Edit', ...own, ...CHROME_SOURCE_NAMES, ...REPAIR_NAMES]);
|
|
23
24
|
const empty = { type: 'object', properties: {}, additionalProperties: false };
|
|
24
25
|
const error = (message, code = 'CHROME_TASK_CONFLICT') => nativePageFailure(message,code);
|
|
25
26
|
const sourceHash = value => digest(value.files.map(({ path, sha256 }) => ({ path, sha256 })));
|
|
@@ -104,7 +105,8 @@ export async function prepareChromePageTask({ cwd, house, store, sessionId, exec
|
|
|
104
105
|
throw failure;
|
|
105
106
|
}
|
|
106
107
|
};
|
|
107
|
-
|
|
108
|
+
// A plugin operation the house runs for a task action: a thrown handler error names the operation and its input (chrome-repair.mjs).
|
|
109
|
+
const invoke = async (options, name, input = {}) => { let text; try { text = await options.invoke(prefix + name, input, { signal: options.signal }); } catch (failure) { throw pluginFailure(failure, { house, options, plugin: 'chrome-lab', operation: name, input }); } return JSON.parse(text); };
|
|
108
110
|
if(recoverySelected)publicationRecovery=createPagePreviewRecoveryV13({house,store,sessionId,cwd,task:brief,taskDigest,plan,policy,transportGuard:publicationRecoveryGuard,snapshot,state,save,invoke});
|
|
109
111
|
let behaviorOperation;
|
|
110
112
|
const context = async () => {
|
|
@@ -342,6 +344,9 @@ export async function prepareChromePageTask({ cwd, house, store, sessionId, exec
|
|
|
342
344
|
return Boolean(frame && PAGE_VERIFIER_CONTRACT[frame.input.phase === 'run' ? 'run_ports' : 'final_ports'].includes(name) && view.input.target_id === (await state()).binding?.target_id && (name !== 'InspectValue' || view.input.context_unique_id === (await state()).binding.identity.context_unique_id));
|
|
343
345
|
}
|
|
344
346
|
if (['WorkPlanSet', 'WorkCheckRepair'].includes(view.operation)) return false;
|
|
347
|
+
// Repair by contract: its operations, the open candidate's files, and replays of calls that failed for this task.
|
|
348
|
+
const repair = await admitRepair(view, { policy, cwd, store, sessionId, prefix, plugin: 'chrome-lab', actions: [...own, PAGE_CASE_OPERATION, PAGE_BEHAVIOR_OPERATION] });
|
|
349
|
+
if (repair !== undefined) return repair;
|
|
345
350
|
if (['Read', 'Write', 'Edit'].includes(view.operation)) return sourcePath(view.input.file_path, view.operation !== 'Read');
|
|
346
351
|
if (CHROME_SOURCE_NAMES.includes(view.operation)) return Boolean(sourceAccess?.active() && await sourceAccess.admit(view));
|
|
347
352
|
if (view.operation === 'FileInfo') return true; // Separate narrow artifact gate.
|
|
@@ -383,7 +388,7 @@ export async function prepareChromePageTask({ cwd, house, store, sessionId, exec
|
|
|
383
388
|
if (initial.current?.status === 'pending') await executor.execute('WorkTaskUpdate', { task_id: 'chrome-task', plan_version: initial.plan.version, status: 'in_progress' });
|
|
384
389
|
sourceAccess = bindChromeSource({ executor, store, sessionId, cwd, plan, taskDigest, pathAllowed: sourcePath, directory: brief.directory });
|
|
385
390
|
await house.activateGovernance({ sessionId, plan, bindings: {
|
|
386
|
-
'runtime:chrome-task.scope': { revision: 'chrome-page-scope-v1', operations: ['*'], configuration_digest: taskDigest, check: scope },
|
|
391
|
+
'runtime:chrome-task.scope': { revision: 'chrome-page-scope-v1-repair2', operations: ['*'], configuration_digest: taskDigest, check: scope },
|
|
387
392
|
'runtime:chrome-task.close': { revision: 'chrome-page-close-v1', operations: ['WorkTaskUpdate'], configuration_digest: taskDigest, check: close },
|
|
388
393
|
'runtime:chrome-task.artifact-hash': { revision: 'chrome-page-artifact-v1', operations: ['FileInfo'], configuration_digest: digest({ taskDigest, artifact, contract_digest: hashOp.digest }), check: async view => !policy.denied.has('FileInfo') && view.contract_digest === hashOp.digest && (['WorkStepRun', 'WorkTaskUpdate'].includes(view.parent_operation) && digest(view.input) === digest({ file_path: artifact, hash: true }) || await sourceAccess.inspection(view)) },
|
|
389
394
|
} });
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { digest, PLUGIN_REPAIR_OPERATION_NAMES } from '../operations.mjs';
|
|
3
|
+
|
|
4
|
+
// Repair by contract inside a coordinated Chrome task. There the model never calls a chrome-lab operation:
|
|
5
|
+
// the house runs them as steps of the task's own actions, under a scope that admits exactly the calls the
|
|
6
|
+
// task needs. A broken promise of the plugin therefore shows up inside a house action, and the model needs
|
|
7
|
+
// three things to repair it without the scope opening anything else:
|
|
8
|
+
// - to be told which plugin operation failed, with what input and what error (pluginFailure);
|
|
9
|
+
// - the five repair operations beside the domain actions, while the plugin is repairable (REPAIR_NAMES);
|
|
10
|
+
// - the calls a repair makes to be admitted, and only those (admitRepair), all for the task's own plugin:
|
|
11
|
+
// · the repair operations themselves, proposed by the model, never nested, naming that plugin or an
|
|
12
|
+
// open candidate of that plugin;
|
|
13
|
+
// · Read/Edit/Write inside the candidate tree PluginRepairOpen opened for that plugin;
|
|
14
|
+
// · under Reproduce, Test and Accept, a plugin call that is EXACTLY one the house ran in this session
|
|
15
|
+
// for the task and that failed (same operation, same arguments), and only when the operation is
|
|
16
|
+
// one a repair may repeat (REPEATABLE).
|
|
17
|
+
// The task's own uncertainty rules are untouched. REPEATABLE is an allow-list: the plugin's read-only
|
|
18
|
+
// operations, plus Open. Every operation that writes outside (the deploy, the fixture preview, an
|
|
19
|
+
// interaction, a message, arbitrary page JavaScript, and any operation added later) is never run again by
|
|
20
|
+
// a repair: whether it took effect is settled by the task's reconciliation, which looks instead of
|
|
21
|
+
// repeating. Opening the task's own popup is the one effect a repair may replay: nothing observes a popup
|
|
22
|
+
// that was never opened, and a second one is seen by the reconciliation as ambiguous, never adopted.
|
|
23
|
+
|
|
24
|
+
export const REPAIR_NAMES = Object.freeze([...PLUGIN_REPAIR_OPERATION_NAMES]);
|
|
25
|
+
export const REPEATABLE = Object.freeze(['Status', 'Deployment', 'Targets', 'ReadDOM', 'Assert', 'Contexts', 'InspectValue', 'Console', 'WorkerDiagnostics', 'Open']);
|
|
26
|
+
const REPLAYING = ['PluginRepairReproduce', 'PluginRepairTest', 'PluginRepairAccept'];
|
|
27
|
+
const INPUT_BYTES = 4096, ERROR_CHARS = 700;
|
|
28
|
+
const NO_REPEAT = 'This call may have taken effect before it failed. It is not repeated, and a repair cannot reproduce it: settle it with ChromeRecover, which observes what is really published and open.';
|
|
29
|
+
|
|
30
|
+
// A candidate tree is <state>/plugin-candidates/<plugin>/<candidate>/tree (plugin-repair.mjs).
|
|
31
|
+
function candidateOf(root) {
|
|
32
|
+
const parts = String(root).split(path.sep);
|
|
33
|
+
return parts.at(-1) === 'tree' && parts.at(-4) === 'plugin-candidates' ? { plugin: parts.at(-3), candidate: parts.at(-2) } : null;
|
|
34
|
+
}
|
|
35
|
+
/** The open candidate trees of `plugin`. */
|
|
36
|
+
export const candidateRoots = (policy, plugin) => [...(policy?.candidateRoots ?? [])].filter(root => candidateOf(root)?.plugin === plugin);
|
|
37
|
+
/** The open candidate tree of `plugin` that holds `file`, or null. */
|
|
38
|
+
export function candidateRoot(policy, cwd, file, plugin) {
|
|
39
|
+
if (typeof file !== 'string' || typeof policy?.candidateRootOf !== 'function') return null;
|
|
40
|
+
const root = policy.candidateRootOf(path.resolve(cwd, file));
|
|
41
|
+
return root && candidateOf(root)?.plugin === plugin ? root : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A scope verdict for a call that belongs to a repair, or undefined when the call is none of a repair's
|
|
46
|
+
* business (the caller's own rules decide). `plugin` is the task's plugin and `prefix` its operation prefix;
|
|
47
|
+
* `actions` are the task's own operations, whose failed nested plugin calls may be replayed.
|
|
48
|
+
*/
|
|
49
|
+
export async function admitRepair(view, { policy, cwd, store, sessionId, prefix, plugin, actions }) {
|
|
50
|
+
if (REPAIR_NAMES.includes(view.operation)) {
|
|
51
|
+
if (view.parent_operation) return { allowed: false, reason: 'A repair operation is proposed by the model, never nested.' };
|
|
52
|
+
const own = ['PluginRepairReproduce', 'PluginRepairOpen'].includes(view.operation) ? view.input?.plugin === plugin
|
|
53
|
+
: candidateRoots(policy, plugin).some(root => candidateOf(root).candidate === view.input?.candidate);
|
|
54
|
+
return own ? true : { allowed: false, reason: `Inside this task only plugin ${plugin} and its own open candidate can be repaired.` };
|
|
55
|
+
}
|
|
56
|
+
if (['Read', 'Edit', 'Write'].includes(view.operation) && candidateRoot(policy, cwd, view.input?.file_path, plugin)) return true;
|
|
57
|
+
if (!REPLAYING.includes(view.parent_operation) || !view.operation.startsWith(prefix)) return undefined;
|
|
58
|
+
if (!REPEATABLE.includes(view.operation.slice(prefix.length))) return { allowed: false, reason: NO_REPEAT };
|
|
59
|
+
const rows = (await store.load(sessionId)).rows, wanted = digest(view.input);
|
|
60
|
+
const receipts = rows.filter(row => row.type === 'house_receipt');
|
|
61
|
+
const failed = receipts.some(row => row.operation === view.operation && row.status === 'failed' && row.arguments_digest === wanted
|
|
62
|
+
&& receipts.some(parent => parent.invocation_id === row.parent_invocation_id && actions.includes(parent.operation)));
|
|
63
|
+
return failed ? true : { allowed: false, reason: 'In this coordinated task a repair replays exactly a plugin call the house ran for the task and that failed (the operation and input the failed action reported). No other plugin call is admitted.' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Refusals before the handler ran (permission, guard, scope, stale catalogue, invalid input): not the plugin's failure.
|
|
67
|
+
const NOT_RUN = /^(HOUSE_(REFUSED|GUARD|STALE|AUTHORITY_CHANGED|SURFACE_CHANGED|INPUT_SCHEMA|PERMISSION_DENIED)|GOVERNANCE_REFUSED|TOOL_PACK_REQUIRED)$/;
|
|
68
|
+
/**
|
|
69
|
+
* Called with the error of a plugin operation the house ran as a step of a task action. When the plugin's
|
|
70
|
+
* handler ran and threw (not a refusal before it ran, not an interrupted turn), the error is given
|
|
71
|
+
* `plugin_failure`: the plugin, the operation, the input and the error. For an operation a repair may
|
|
72
|
+
* replay it says how to reproduce it (when the repair operations are offered); for one the task never
|
|
73
|
+
* repeats it says so and names the reconciliation instead. The error keeps its identity, code and message;
|
|
74
|
+
* it is returned for the caller to rethrow. Synchronous and cheap: the task's own polling loops pass through here.
|
|
75
|
+
*/
|
|
76
|
+
export function pluginFailure(error, { house, options, plugin, operation, input }) {
|
|
77
|
+
if (!error || typeof error !== 'object' || error.plugin_failure || options?.signal?.aborted || NOT_RUN.test(String(error.code ?? ''))) return error;
|
|
78
|
+
let shown = {}; try { const text = JSON.stringify(input ?? {}); shown = Buffer.byteLength(text) <= INPUT_BYTES ? { input: JSON.parse(text) } : { input_omitted: 'too_large' }; } catch { shown = { input_omitted: 'not_json' }; }
|
|
79
|
+
const repairable = REPAIR_NAMES.every(name => house?.registry?.get(name));
|
|
80
|
+
const advice = !REPEATABLE.includes(operation) ? { next_action: NO_REPEAT }
|
|
81
|
+
: repairable && shown.input ? { repair: `The house ran this plugin operation for your action and it failed. If the cause is the page, the source or the lab, correct that. If the plugin itself broke its promise, reproduce exactly this call: PluginRepairReproduce {"plugin":"${plugin}","case":{"name":"<what it should do>","operation":"${operation}","input":<the input above>,"expect":<what it should return>}}, then PluginRepairOpen, fix the candidate, PluginRepairAccept. After an accepted repair continue with the task's own actions; an uncertain publication is settled with ChromeRecover, not repeated.` } : {};
|
|
82
|
+
try { error.plugin_failure = { plugin, operation, ...shown, ...(typeof error.code === 'string' ? { code: error.code } : {}), error: String(error.message ?? error).slice(0, ERROR_CHARS), ...advice }; } catch {}
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const escaped = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
87
|
+
/** The alternatives a task's file_path pattern gains while candidate trees of `plugin` are open: each tree, escaped. */
|
|
88
|
+
export const candidatePathAlternatives = (policy, plugin) => candidateRoots(policy, plugin).map(root => escaped(root) + '/');
|
|
@@ -10,6 +10,7 @@ import { CHROME_SOURCE_NAMES, bindChromeSource } from './chrome-source.mjs';
|
|
|
10
10
|
import { PAGE_PREVIEW_RECOVERY_V13 } from './chrome-page-recovery-v13.mjs';
|
|
11
11
|
import { suppliedInputsSchema, normalizeSuppliedInputs, verifySuppliedInputs, isSuppliedPath, assertSuppliedSnapshot } from './chrome-supplied-inputs.mjs';
|
|
12
12
|
import { safeRelative, snapshotProject, chromeProjectNameSchema } from '../plugins/chrome-lab/project.mjs';
|
|
13
|
+
import { REPAIR_NAMES, admitRepair, pluginFailure, candidatePathAlternatives } from './chrome-repair.mjs';
|
|
13
14
|
|
|
14
15
|
const prefix = 'plugin__chrome-lab__';
|
|
15
16
|
const PUBLIC = new Set(['Read', 'Edit', 'Write', 'ChromePublish', 'ChromeVerify', 'ChromeTest', 'ChromeFinish', 'ChromeRecover', 'ChromeInspect', 'ChromeDeliver']);
|
|
@@ -145,7 +146,9 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
|
|
|
145
146
|
};
|
|
146
147
|
const snapshot = async signal => {if(suppliedDigest)await verifySuppliedInputs(directory,brief.supplied_inputs,{signal});const result=await snapshotProject(cwd, brief.directory, { signal }); if(fixtureSource&&result.files.find(f=>f.path===brief.fixture.file)?.sha256!==fixtureSource)throw failure('The immutable test fixture changed.','CHROME_TASK_FIXTURE_CHANGED');if(suppliedDigest)assertSuppliedSnapshot(result,brief.supplied_inputs); return result;};
|
|
147
148
|
const save = (options, value) => options.recordWork({ type: 'chrome_task_checkpoint', source: 'workflow_host', task_digest: taskDigest, state: value });
|
|
148
|
-
|
|
149
|
+
// A plugin operation the house runs for a task action. When its handler throws, the error says which
|
|
150
|
+
// operation failed and with what input (chrome-repair.mjs), so the model can tell a broken plugin from a broken page.
|
|
151
|
+
const invoke = async (options, name, input) => { let text; try { text = await options.invoke(prefix+name, input, { signal: options.signal }); } catch (error) { throw pluginFailure(error, { house, options, plugin: 'chrome-lab', operation: name, input }); } return JSON.parse(text); };
|
|
149
152
|
const assertReady = value => { if (value.stage.endsWith('_pending') || value.status === 'uncertain') throw failure('Publicación incierta: no se repite. Requiere reconciliación explícita.', 'CHROME_TASK_UNCERTAIN'); };
|
|
150
153
|
const observeDocument = async (options,binding,previousIdentity) => {
|
|
151
154
|
if(!observeIdentity)return;
|
|
@@ -416,6 +419,9 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
|
|
|
416
419
|
if (policy.denied.has(view.operation)) return { allowed: false, reason: 'Explicit session deny.' };
|
|
417
420
|
if (view.operation === 'WorkPlanSet' || view.operation === 'WorkCheckRepair') return { allowed: false, reason: 'Accepted task/criterion is immutable in this workflow.' };
|
|
418
421
|
if (view.actor.kind === 'human' || view.surface === 'internal') return true;
|
|
422
|
+
// Repair by contract: its operations, the open candidate's files, and replays of calls that failed for this task.
|
|
423
|
+
const repair = await admitRepair(view, { policy, cwd, store, sessionId, prefix, plugin: 'chrome-lab', actions: OWN });
|
|
424
|
+
if (repair !== undefined) return repair;
|
|
419
425
|
if (['Read','Edit','Write'].includes(view.operation)) return sourcePath(view.input.file_path,view.operation!=='Read');
|
|
420
426
|
if (retainedSource && CHROME_SOURCE_NAMES.includes(view.operation)) return Boolean(sourceAccess?.active() && await sourceAccess.admit(view));
|
|
421
427
|
if (!legacy && view.operation === 'FileInfo') return { allowed: true, reason: 'Subject to the separately enforced artifact-hash capability.' };
|
|
@@ -460,16 +466,19 @@ export async function prepareChromeTask({ cwd, house, store, sessionId, executor
|
|
|
460
466
|
if (initial.current?.status === 'pending') await executor.execute('WorkTaskUpdate', { task_id: 'chrome-task', plan_version: initial.plan.version, status: 'in_progress' });
|
|
461
467
|
if(retainedSource)sourceAccess=bindChromeSource({executor,store,sessionId,cwd,plan,taskDigest,pathAllowed:async(file,mutable)=>(await sourcePath(file,mutable)).allowed,directory:brief.directory});
|
|
462
468
|
await house.activateGovernance({ sessionId, plan, bindings: {
|
|
463
|
-
'runtime:chrome-task.scope': { revision: suppliedInputs?'chrome-scope-v15':retainedSource?'chrome-scope-v14':workerInspection?'chrome-scope-v13':legacy ? 'chrome-scope-v3' : documentProof ? 'chrome-scope-v12' : pages ? 'chrome-scope-v11' : pipeline ? 'chrome-scope-v9' : currency ? 'chrome-scope-v7' : inspection ? 'chrome-scope-v6' : 'chrome-scope-v5', operations: ['*'], configuration_digest: taskDigest, check: scope },
|
|
469
|
+
'runtime:chrome-task.scope': { revision: (suppliedInputs?'chrome-scope-v15':retainedSource?'chrome-scope-v14':workerInspection?'chrome-scope-v13':legacy ? 'chrome-scope-v3' : documentProof ? 'chrome-scope-v12' : pages ? 'chrome-scope-v11' : pipeline ? 'chrome-scope-v9' : currency ? 'chrome-scope-v7' : inspection ? 'chrome-scope-v6' : 'chrome-scope-v5')+'-repair2', operations: ['*'], configuration_digest: taskDigest, check: scope },
|
|
464
470
|
'runtime:chrome-task.close': { revision: 'chrome-close-v1', operations: ['WorkTaskUpdate'], configuration_digest: taskDigest, check: close },
|
|
465
471
|
...(!legacy ? { 'runtime:chrome-task.artifact-hash': { revision: retainedSource?'chrome-artifact-hash-v2':'chrome-artifact-hash-v1', operations: ['FileInfo'], configuration_digest: digest({ taskDigest, artifacts: declaredTask.artifacts, contract_digest: hashOperation.digest }), check: artifactHash } } : {})
|
|
466
472
|
} });
|
|
467
473
|
const project = snapshot => {
|
|
468
|
-
|
|
474
|
+
// The repair operations are offered beside the domain actions while the house offers them at all (a local
|
|
475
|
+
// plugin is repairable); while a candidate is open, the file tools also name its tree.
|
|
476
|
+
const candidates=candidatePathAlternatives(policy, 'chrome-lab');
|
|
477
|
+
const definitions = snapshot.definitions().filter(tool => PUBLIC.has(tool.name) || retainedSource && CHROME_SOURCE_NAMES.includes(tool.name) || REPAIR_NAMES.includes(tool.name)).map(tool=>{
|
|
469
478
|
if(!pages||!['Read','Write','Edit',...(retainedSource?['SourceDraft']:[])].includes(tool.name))return tool;
|
|
470
479
|
const escaped=value=>value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
471
|
-
const view=structuredClone(tool);view.input_schema.properties.file_path.pattern='^('+escaped(brief.directory)+'
|
|
472
|
-
view.input_schema.properties.file_path.description='Accepted extension source path under '+brief.directory+'/. Host/plugin files are outside this task. '+(brief.fixture?'The fixture '+brief.fixture.file+' is read-only. ':'')+(suppliedDigest?'Supplied read-only inputs: '+brief.supplied_inputs.map(item=>item.file).join(', ')+'. ':'')+'Use existing source observations; reread only an unresolved file.';
|
|
480
|
+
const view=structuredClone(tool);view.input_schema.properties.file_path.pattern='^('+[escaped(brief.directory)+'/',escaped(directory)+'/',...(tool.name==='SourceDraft'?[]:candidates)].join('|')+').+$';
|
|
481
|
+
view.input_schema.properties.file_path.description='Accepted extension source path under '+brief.directory+'/. Host/plugin files are outside this task. '+(brief.fixture?'The fixture '+brief.fixture.file+' is read-only. ':'')+(suppliedDigest?'Supplied read-only inputs: '+brief.supplied_inputs.map(item=>item.file).join(', ')+'. ':'')+'Use existing source observations; reread only an unresolved file.'+(candidates.length&&tool.name!=='SourceDraft'?' While a plugin repair candidate is open, its directory (the one PluginRepairOpen returned) is admitted too.':'');
|
|
473
482
|
return view;
|
|
474
483
|
});
|
|
475
484
|
const offered = new Set(definitions.map(tool => tool.name));
|