agentera 3.0.0-dev.79 → 3.0.0-dev.80
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/bundle/.agentera-build-source.json +5 -5
- package/bundle/CHANGELOG.md +9 -0
- package/bundle/references/adapters/package-registry.yaml +147 -145
- package/bundle/references/artifacts/state-storage-authority.yaml +27 -18
- package/bundle/skills/agentera/schemas/artifacts/todo.yaml +6 -3
- package/dist/.agentera-build-source.json +5 -5
- package/dist/cli/capabilityContext/progress.js +7 -6
- package/dist/cli/commands/prime.js +13 -0
- package/dist/cli/commands/state/write.js +29 -3
- package/dist/cli/help.js +10 -4
- package/dist/state/todoCreateBatch.js +69 -0
- package/dist/state/todoDocsEntities.js +212 -2
- package/dist/state/todoReconciliationTransaction.js +41 -8
- package/dist/state/todoTransitionBatch.js +65 -0
- package/dist/state/todoUpdateBatch.js +50 -0
- package/dist/state/write/explain.js +6 -3
- package/dist/state/write/runtimeOperations.js +8 -8
- package/package.json +2 -2
|
@@ -23,6 +23,9 @@ import { normalizeTodoOwnerCorrectionEvidence, planTodoOwnerCorrection, planTodo
|
|
|
23
23
|
import { readTodoMarkdown, renderManagedMarkdown } from "./todoMarkdownProjection.js";
|
|
24
24
|
import { inactiveTodoActivationSafety, rejectUnsafeInactiveTodoActivation, unsafeInactiveDuplicateDiagnosis } from "./todoActivationSafety.js";
|
|
25
25
|
import { assertTodoSeverityHeadingStructure, todoSeveritySectionForHeading } from "./todoSeverityHeadings.js";
|
|
26
|
+
import { parseTodoUpdateBatch, todoUpdateBatchEffectSha256 } from "./todoUpdateBatch.js";
|
|
27
|
+
import { parseTodoCreateBatch, resolveTodoCreateBatchRecords, todoCreateBatchEffectSha256 } from "./todoCreateBatch.js";
|
|
28
|
+
import { matchesTodoTransitionBatchPostState, parseTodoTransitionBatch, todoTransitionBatchPostStateSha256 } from "./todoTransitionBatch.js";
|
|
26
29
|
const ID = /^[a-z]{10}$/;
|
|
27
30
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
28
31
|
const TODO = { artifact: "todo", boundary: "todo_item", order: "severity_then_status_then_markdown_order_then_id" };
|
|
@@ -411,7 +414,7 @@ function importMarkdown(record, row) {
|
|
|
411
414
|
result.status = row.item.status;
|
|
412
415
|
return result;
|
|
413
416
|
}
|
|
414
|
-
function withBaseline(record, value) { const publicValue = Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)); return { ...record, reconciliation: { schema_version: RECONCILIATION_VERSION, public: publicValue } }; }
|
|
417
|
+
function withBaseline(record, value) { const publicValue = Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)); const transitionBatch = mapping(record.reconciliation) && mapping(record.reconciliation.transition_batch) ? structuredClone(record.reconciliation.transition_batch) : null; return { ...record, reconciliation: { schema_version: RECONCILIATION_VERSION, public: publicValue, ...(transitionBatch ? { transition_batch: transitionBatch } : {}) } }; }
|
|
415
418
|
function inactiveTodoMutation() { reject({ class: "conflict", message: "TODO reconciliation is inactive; ordinary TODO mutations cannot activate it implicitly", syntax: TODO_ACTIVATION_PREVIEW_COMMAND, example: TODO_ACTIVATION_APPLY_COMMAND, recovery: `Run exactly '${TODO_ACTIVATION_PREVIEW_COMMAND}', review every reported effect, then run exactly '${TODO_ACTIVATION_APPLY_COMMAND}'; no state was changed.` }); }
|
|
416
419
|
function activationEnvelope(effect, dryRun, replay, transactionId, targets, recovered) { return { schemaVersion: "agentera.stateWrite.v1", command: "state todo activate", status: "pass", path: TODO_RECONCILIATION_ACTIVATION_PATH, artifact: "todo", operation: { verb: "activate", dry_run: dryRun, idempotent_replay: replay }, validation: { status: "pass", violations: [] }, activation: effect, apply_command: TODO_ACTIVATION_APPLY_COMMAND.replace("EFFECT_SHA256", String(effect.effect_sha256)), reconciliation: { transaction_id: transactionId, targets, recovered } }; }
|
|
417
420
|
function repairEnvelope(effect, dryRun, replay, transactionId, targets, recovered) { return { schemaVersion: "agentera.stateWrite.v1", command: "state todo repair", status: "pass", path: TODO_RECONCILIATION_ACTIVATION_PATH, artifact: "todo", operation: { verb: "repair", dry_run: dryRun, idempotent_replay: replay }, validation: { status: "pass", violations: [] }, repair: effect, apply_command: TODO_REPAIR_APPLY_COMMAND.replace("EFFECT_SHA256", String(effect.effect_sha256)), reconciliation: { transaction_id: transactionId, targets, recovered } }; }
|
|
@@ -671,6 +674,9 @@ export function mutateTodoDocsEntity(req, options = {}) {
|
|
|
671
674
|
const todoBinding = artifact === "todo" ? todoReconciliationBinding(req.projectRoot, sourceRoot) : null;
|
|
672
675
|
const correctingOwners = artifact === "todo" && req.spec.verb === "correct-owners";
|
|
673
676
|
const ownerEvidence = correctingOwners ? normalizeTodoOwnerCorrectionEvidence(req.input ?? {}) : null;
|
|
677
|
+
const batch = artifact === "todo" && req.spec.verb === "update" ? parseTodoUpdateBatch(req.input) : null;
|
|
678
|
+
const createBatch = artifact === "todo" && req.spec.verb === "create" ? parseTodoCreateBatch(req.input) : null;
|
|
679
|
+
const transitionBatch = artifact === "todo" ? parseTodoTransitionBatch(req.input, req.spec.verb) : null;
|
|
674
680
|
const pending = todoBinding ? inspectTodoReconciliation(pinnedRoot, todoBinding) : [];
|
|
675
681
|
if (artifact === "todo")
|
|
676
682
|
assertTodoSeverityHeadingStructure(readTodoMarkdown(todoPublicPath(pinnedRoot, sourceRoot)).text);
|
|
@@ -690,13 +696,15 @@ export function mutateTodoDocsEntity(req, options = {}) {
|
|
|
690
696
|
inactiveTodoMutation();
|
|
691
697
|
if (req.dryRun && pending.length)
|
|
692
698
|
reject({ class: "conflict", message: `TODO reconciliation transaction '${pending[0]}' requires recovery before dry-run`, recovery: "Retry the exact TODO mutation without --dry-run once to complete recovery; this dry-run changed no state." });
|
|
693
|
-
const createRequest = artifact === "todo" && req.spec.verb === "create" ? mutationRecord(req, undefined) : null;
|
|
699
|
+
const createRequest = artifact === "todo" && req.spec.verb === "create" && !createBatch ? mutationRecord(req, undefined) : null;
|
|
694
700
|
const createRequestSha256 = createRequest ? todoCreateRequestSha256(createRequest) : undefined;
|
|
695
701
|
const recoveryReceipts = todoBinding && !req.dryRun
|
|
696
702
|
? recoverTodoReconciliation(context, sourceRoot, todoBinding, {
|
|
697
703
|
createRequestSha256,
|
|
698
704
|
...(["activate", "repair", "correct-owners"].includes(req.spec.verb) ? { activationEffectSha256: String(req.values.effect_sha256) } : {}),
|
|
699
705
|
...(correctingOwners ? { ownerMappingSha256: ownerEvidence.sha256 } : {}),
|
|
706
|
+
...(batch || transitionBatch ? { updateBatch: { effectSha256: String(req.values.effect_sha256 ?? ""), input: req.input } } : {}),
|
|
707
|
+
...(createBatch ? { createBatch: { effectSha256: String(req.values.effect_sha256 ?? ""), input: req.input } } : {}),
|
|
700
708
|
beforeCommit: () => assertState(pinnedRoot, sourceRoot, sourceBinding),
|
|
701
709
|
})
|
|
702
710
|
: [];
|
|
@@ -711,6 +719,15 @@ export function mutateTodoDocsEntity(req, options = {}) {
|
|
|
711
719
|
const created = selectedById(entities, "todo", recoveredCreate.create.created_id);
|
|
712
720
|
return { ...envelope("state todo create", { id: created.id, path: created.path, replay: true }, "todo", created.record, false), reconciliation: { transaction_id: recoveredCreate.transaction_id, targets: 0, recovered } };
|
|
713
721
|
}
|
|
722
|
+
const recoveredBatch = recoveryReceipts.find((receipt) => receipt.update_batch_effect_sha256);
|
|
723
|
+
if (recoveredBatch?.update_batch_effect_sha256 && (batch || transitionBatch)) {
|
|
724
|
+
const verb = transitionBatch?.verb ?? "update";
|
|
725
|
+
const entries = transitionBatch?.entries ?? batch;
|
|
726
|
+
return { schemaVersion: "agentera.stateWrite.v1", command: `state todo ${verb}`, status: "pass", artifact: "todo", records: entries.map(({ id }) => ({ id, record: selectedById(entities, "todo", id).record })), operation: { verb, dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: recoveredBatch.update_batch_effect_sha256, apply_command: null, reconciliation: { transaction_id: recoveredBatch.transaction_id, targets: recoveredBatch.target_count, recovered } };
|
|
727
|
+
}
|
|
728
|
+
const recoveredCreateBatch = recoveryReceipts.find((receipt) => receipt.create_batch);
|
|
729
|
+
if (recoveredCreateBatch?.create_batch && createBatch)
|
|
730
|
+
return { schemaVersion: "agentera.stateWrite.v1", command: "state todo create", status: "pass", artifact: "todo", records: Object.entries(recoveredCreateBatch.create_batch.local_refs).map(([local_ref, id]) => ({ local_ref, id, record: selectedById(entities, "todo", id).record })), local_refs: recoveredCreateBatch.create_batch.local_refs, operation: { verb: "create", dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: recoveredCreateBatch.create_batch.effect_sha256, apply_command: null, reconciliation: { transaction_id: recoveredCreateBatch.transaction_id, targets: recoveredCreateBatch.target_count, recovered } };
|
|
714
731
|
const publicFile = todoPublicPath(pinnedRoot, sourceRoot);
|
|
715
732
|
const publicRelative = todoBinding.publicPath;
|
|
716
733
|
const publicExists = fs.existsSync(publicFile);
|
|
@@ -868,6 +885,199 @@ export function mutateTodoDocsEntity(req, options = {}) {
|
|
|
868
885
|
const scan = managedRows(markdown, activation, todoEntities);
|
|
869
886
|
const rows = scan.rows;
|
|
870
887
|
const reconciled = reconcileTodoRecords(todoEntities, rows, activating);
|
|
888
|
+
if (createBatch) {
|
|
889
|
+
if (!req.dryRun && (!req.values.confirmed || !SHA256.test(String(req.values.effect_sha256 ?? ""))))
|
|
890
|
+
reject({ class: "invalid_request", message: "todo create batch apply requires its preview effect SHA-256 and --yes", recovery: "Run the same batch input with --dry-run, review it, then repeat that input with the returned --effect-sha256 value and --yes; no state was changed." });
|
|
891
|
+
const inputSha256 = createHash("sha256").update(canonicalRecordJson(req.input)).digest("hex");
|
|
892
|
+
const prior = todoEntities.filter(({ record }) => mapping(record?.reconciliation) && mapping(record.reconciliation.create_batch) && record.reconciliation.create_batch.input_sha256 === inputSha256);
|
|
893
|
+
if (prior.length) {
|
|
894
|
+
const createReceipt = prior[0].record.reconciliation.create_batch;
|
|
895
|
+
const localRefs = createReceipt.local_refs;
|
|
896
|
+
if (prior.length !== createBatch.length || prior.some(({ id, record }) => canonicalRecordJson(record.reconciliation.create_batch) !== canonicalRecordJson(createReceipt) || !Object.values(localRefs).includes(id)) || req.values.effect_sha256 !== createReceipt.effect_sha256)
|
|
897
|
+
reject({ class: "conflict", message: "existing TODO create batch receipt is incomplete or does not match this effect authorization", recovery: "Restore the complete original create batch result or use its exact input and effect SHA-256; no state was changed." });
|
|
898
|
+
return { schemaVersion: "agentera.stateWrite.v1", command: "state todo create", status: "pass", artifact: "todo", records: createBatch.map(({ local_ref }) => { const id = localRefs[local_ref]; return { local_ref, id, record: selectedById(todoEntities, "todo", id).record }; }), local_refs: localRefs, operation: { verb: "create", dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: createReceipt.effect_sha256, apply_command: null, reconciliation: { transaction_id: null, targets: 0, recovered } };
|
|
899
|
+
}
|
|
900
|
+
const allocated = new Set();
|
|
901
|
+
const localIds = new Map();
|
|
902
|
+
for (const { local_ref } of createBatch) {
|
|
903
|
+
let salt = 0;
|
|
904
|
+
let id;
|
|
905
|
+
do {
|
|
906
|
+
const digest = createHash("sha256").update(inputSha256).update("\0").update(local_ref).update("\0").update(String(salt++)).digest();
|
|
907
|
+
id = Array.from(digest.subarray(0, 10), (byte) => String.fromCharCode(97 + byte % 26)).join("");
|
|
908
|
+
try {
|
|
909
|
+
id = allocateEntityId(context.pinnedPath(), () => id, sourceRoot);
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
if (!String(error.message).includes("could not allocate"))
|
|
913
|
+
throw error;
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
} while (allocated.has(id));
|
|
917
|
+
allocated.add(id);
|
|
918
|
+
localIds.set(local_ref, id);
|
|
919
|
+
}
|
|
920
|
+
const resolved = resolveTodoCreateBatchRecords(createBatch, localIds);
|
|
921
|
+
for (const { local_ref, record } of resolved) {
|
|
922
|
+
const id = localIds.get(local_ref);
|
|
923
|
+
reconciled.records.set(id, mutationRecord({ ...req, input: record, values: {}, callerPayload: record }, undefined));
|
|
924
|
+
reconciled.visible.add(id);
|
|
925
|
+
}
|
|
926
|
+
const referenceEntities = [...reconciled.records].map(([todoId, record]) => ({ boundary: TODO.boundary, id: todoId, record }));
|
|
927
|
+
for (const [todoId, record] of reconciled.records) {
|
|
928
|
+
const violations = recordViolations("todo", record, sourceRoot);
|
|
929
|
+
if (violations.length)
|
|
930
|
+
reject({ class: "schema_violation", message: "todo entity input is invalid", violations, recovery: "Correct the batch record and preview the complete batch again; no state was changed." });
|
|
931
|
+
if (record.readiness !== undefined)
|
|
932
|
+
assertTodoReferences(todoId, record, referenceEntities);
|
|
933
|
+
}
|
|
934
|
+
const visibleRecords = new Map([...reconciled.records].filter(([todoId]) => reconciled.visible.has(todoId)));
|
|
935
|
+
const rendered = renderManagedMarkdown(markdown, visibleRecords, rows);
|
|
936
|
+
const activationBytesAfter = activation ? loadedActivation.bytes.toString("utf8") : todoReconciliationActivationBytes(scan.retainedLegacyRows);
|
|
937
|
+
const activationAfter = activation ?? JSON.parse(activationBytesAfter);
|
|
938
|
+
const finalRows = managedRows(rendered, activationAfter, [...todoEntities, ...[...localIds.values()].map((id) => ({ boundary: TODO.boundary, id, record: reconciled.records.get(id) }))]).rows;
|
|
939
|
+
for (const [todoId, record] of reconciled.records) {
|
|
940
|
+
const row = finalRows.get(todoId);
|
|
941
|
+
reconciled.records.set(todoId, withBaseline(record, row ? rowSnapshot(row, record) : { present: false }));
|
|
942
|
+
}
|
|
943
|
+
const localRefs = Object.fromEntries(localIds);
|
|
944
|
+
const existingTargets = todoEntities.map((entity) => ({ path: entity.relativePath, before: exactDiscoveredEntityBytes(entity), after: canonicalEntityEnvelopeBytes({ id: entity.id, artifact: "todo", record: reconciled.records.get(entity.id), migrationProvenance: entity.migrationProvenance ?? undefined }) }));
|
|
945
|
+
const newTargets = [...localIds.values()].map((id) => ({ path: relative(req.projectRoot, targetPath(req.projectRoot, sourceRoot, "todo", id)), before: null, after: "" }));
|
|
946
|
+
const targets = [...existingTargets, ...newTargets];
|
|
947
|
+
if (activating)
|
|
948
|
+
targets.push({ path: TODO_RECONCILIATION_ACTIVATION_PATH, before: null, after: activationBytesAfter });
|
|
949
|
+
targets.push({ path: publicRelative, before: publicExists ? markdownBefore : null, after: rendered });
|
|
950
|
+
const ordered = targets.filter((target) => target.before === null || !target.before.equals(Buffer.from(target.after))).sort((left, right) => Number(left.path === TODO_RECONCILIATION_ACTIVATION_PATH) - Number(right.path === TODO_RECONCILIATION_ACTIVATION_PATH) || Number(left.path === publicRelative) - Number(right.path === publicRelative) || left.path.localeCompare(right.path));
|
|
951
|
+
let effectSha256 = todoCreateBatchEffectSha256(req.input, todoBinding.mappingSha256, localRefs, ordered.filter((target) => !newTargets.includes(target)).map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
|
|
952
|
+
const batchReceipt = { effect_sha256: effectSha256, input_sha256: inputSha256, local_refs: localRefs };
|
|
953
|
+
for (const [localRef, id] of localIds) {
|
|
954
|
+
const record = reconciled.records.get(id);
|
|
955
|
+
record.reconciliation = { ...record.reconciliation, create_batch: batchReceipt };
|
|
956
|
+
newTargets.find((target) => target.path.endsWith(`/${id}.yaml`)).after = canonicalEntityEnvelopeBytes({ id, artifact: "todo", record });
|
|
957
|
+
}
|
|
958
|
+
effectSha256 = todoCreateBatchEffectSha256(req.input, todoBinding.mappingSha256, localRefs, ordered.filter((target) => !newTargets.includes(target)).map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
|
|
959
|
+
batchReceipt.effect_sha256 = effectSha256;
|
|
960
|
+
for (const [, id] of localIds) {
|
|
961
|
+
const record = reconciled.records.get(id);
|
|
962
|
+
record.reconciliation.create_batch = batchReceipt;
|
|
963
|
+
newTargets.find((target) => target.path.endsWith(`/${id}.yaml`)).after = canonicalEntityEnvelopeBytes({ id, artifact: "todo", record });
|
|
964
|
+
}
|
|
965
|
+
const response = { schemaVersion: "agentera.stateWrite.v1", command: "state todo create", status: "pass", artifact: "todo", records: createBatch.map(({ local_ref }) => ({ local_ref, id: localIds.get(local_ref), record: reconciled.records.get(localIds.get(local_ref)) })), local_refs: localRefs, operation: { verb: "create", dry_run: req.dryRun, idempotent_replay: false }, validation: { status: "pass", violations: [] }, effect_sha256: effectSha256, apply_command: req.dryRun ? `agentera state todo create --input <same-input> --effect-sha256 ${effectSha256} --yes --format json` : null };
|
|
966
|
+
if (req.dryRun)
|
|
967
|
+
return { ...response, reconciliation: { transaction_id: null, targets: targets.length, recovered } };
|
|
968
|
+
if (req.values.effect_sha256 !== effectSha256)
|
|
969
|
+
reject({ class: "conflict", message: "TODO create batch effects changed after preview", recovery: "Rerun the same batch input with --dry-run, review the new bounded effect, then use its exact effect SHA-256; no state was changed." });
|
|
970
|
+
const transaction = publishTodoReconciliation(context, sourceRoot, todoBinding, targets, { createBatch: batchReceipt, interruptAfterTarget: options.interruptAfterTarget, beforeCommit: () => { assertState(pinnedRoot, sourceRoot, sourceBinding); const currentBinding = todoReconciliationBinding(req.projectRoot, sourceRoot); if (currentBinding.publicPath !== todoBinding.publicPath || currentBinding.mappingSha256 !== todoBinding.mappingSha256)
|
|
971
|
+
reject({ class: "conflict", message: "TODO reconciliation mapping changed during create batch publication", recovery: "Preserve the changed docs mapping and retry after every transaction target is restored; no mapping bytes were overwritten." }); } });
|
|
972
|
+
context.assertValid();
|
|
973
|
+
return { ...response, reconciliation: { transaction_id: transaction.id, targets: transaction.targetCount, recovered } };
|
|
974
|
+
}
|
|
975
|
+
if (transitionBatch) {
|
|
976
|
+
const verb = transitionBatch.verb;
|
|
977
|
+
if (!req.dryRun && (!req.values.confirmed || !SHA256.test(String(req.values.effect_sha256 ?? ""))))
|
|
978
|
+
reject({ class: "invalid_request", message: `todo ${verb} batch apply requires its preview effect SHA-256 and --yes`, recovery: "Run the same batch input with --dry-run, review it, then repeat that input with the returned --effect-sha256 value and --yes; no state was changed." });
|
|
979
|
+
const inputSha256 = createHash("sha256").update(canonicalRecordJson(req.input)).digest("hex");
|
|
980
|
+
const priorReceipts = transitionBatch.entries.map(({ id }) => mapping(reconciled.records.get(id)?.reconciliation) ? reconciled.records.get(id).reconciliation.transition_batch : null);
|
|
981
|
+
if (priorReceipts.every((receipt) => mapping(receipt) && receipt.input_sha256 === inputSha256 && receipt.verb === verb && receipt.effect_sha256 === req.values.effect_sha256)) {
|
|
982
|
+
const divergent = transitionBatch.entries.find((entry, index) => !matchesTodoTransitionBatchPostState(reconciled.records.get(entry.id), priorReceipts[index], entry, verb));
|
|
983
|
+
if (divergent)
|
|
984
|
+
reject({ class: "conflict", message: `TODO ${verb} batch member '${divergent.id}' no longer matches its authorized post-state`, recovery: "Preserve the current singleton result and preview a fresh batch from current state; no state was changed." });
|
|
985
|
+
return { schemaVersion: "agentera.stateWrite.v1", command: `state todo ${verb}`, status: "pass", artifact: "todo", records: transitionBatch.entries.map(({ id }) => ({ id, record: reconciled.records.get(id) })), operation: { verb, dry_run: false, idempotent_replay: true }, validation: { status: "pass", violations: [] }, effect_sha256: req.values.effect_sha256, apply_command: null, reconciliation: { transaction_id: null, targets: 0, recovered } };
|
|
986
|
+
}
|
|
987
|
+
for (const { id } of transitionBatch.entries) {
|
|
988
|
+
selectedById(todoEntities, "todo", id);
|
|
989
|
+
if (verb === "resolve" && reconciled.records.get(id).status !== "open")
|
|
990
|
+
reject({ class: "conflict", message: "TODO resolve requires an open item", recovery: "Remove already-resolved items from the batch, then preview the complete open-item batch again; no state was changed." });
|
|
991
|
+
}
|
|
992
|
+
const requestedRecords = [];
|
|
993
|
+
for (const entry of transitionBatch.entries) {
|
|
994
|
+
const values = { id: entry.id, ...(entry.severity ? { severity: entry.severity } : {}), lifecycle: { reason: entry.reason, date: entry.date } };
|
|
995
|
+
const record = transitionRecord({ ...req, input: null, values, callerPayload: values }, reconciled.records.get(entry.id), todoEntities.map((entity) => ({ ...entity, record: reconciled.records.get(entity.id) })));
|
|
996
|
+
reconciled.records.set(entry.id, record);
|
|
997
|
+
requestedRecords.push({ id: entry.id, record });
|
|
998
|
+
}
|
|
999
|
+
for (const [todoId, record] of reconciled.records) {
|
|
1000
|
+
const violations = recordViolations("todo", record, sourceRoot);
|
|
1001
|
+
if (violations.length)
|
|
1002
|
+
reject({ class: "schema_violation", message: `todo ${verb} batch input is invalid`, violations, recovery: "Correct the complete batch and preview it again; no state was changed." });
|
|
1003
|
+
}
|
|
1004
|
+
const visibleRecords = new Map([...reconciled.records].filter(([todoId]) => reconciled.visible.has(todoId)));
|
|
1005
|
+
const rendered = renderManagedMarkdown(markdown, visibleRecords, rows);
|
|
1006
|
+
const activationBytesAfter = activation ? loadedActivation.bytes.toString("utf8") : todoReconciliationActivationBytes(scan.retainedLegacyRows);
|
|
1007
|
+
const activationAfter = activation ?? JSON.parse(activationBytesAfter);
|
|
1008
|
+
const finalRows = managedRows(rendered, activationAfter, todoEntities).rows;
|
|
1009
|
+
for (const [todoId, record] of reconciled.records) {
|
|
1010
|
+
const row = finalRows.get(todoId);
|
|
1011
|
+
reconciled.records.set(todoId, withBaseline(record, row ? rowSnapshot(row, record) : { present: false }));
|
|
1012
|
+
}
|
|
1013
|
+
for (const { id } of transitionBatch.entries) {
|
|
1014
|
+
const reconciliation = reconciled.records.get(id).reconciliation;
|
|
1015
|
+
delete reconciliation.transition_batch;
|
|
1016
|
+
}
|
|
1017
|
+
const targets = todoEntities.map((entity) => ({ path: entity.relativePath, before: exactDiscoveredEntityBytes(entity), after: canonicalEntityEnvelopeBytes({ id: entity.id, artifact: "todo", record: reconciled.records.get(entity.id), migrationProvenance: entity.migrationProvenance ?? undefined }) }));
|
|
1018
|
+
targets.push({ path: publicRelative, before: publicExists ? markdownBefore : null, after: rendered });
|
|
1019
|
+
const effectTargets = targets.filter((target) => target.before === null || !target.before.equals(Buffer.from(target.after))).sort((left, right) => Number(left.path === publicRelative) - Number(right.path === publicRelative) || left.path.localeCompare(right.path));
|
|
1020
|
+
const effectSha256 = todoUpdateBatchEffectSha256(req.input, todoBinding.mappingSha256, effectTargets.map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
|
|
1021
|
+
const response = { schemaVersion: "agentera.stateWrite.v1", command: `state todo ${verb}`, status: "pass", artifact: "todo", records: requestedRecords.map(({ id }) => ({ id, record: reconciled.records.get(id) })), operation: { verb, dry_run: req.dryRun, idempotent_replay: false }, validation: { status: "pass", violations: [] }, effect_sha256: effectSha256, apply_command: req.dryRun ? `agentera state todo ${verb} --input <same-input> --effect-sha256 ${effectSha256} --yes --format json` : null };
|
|
1022
|
+
if (req.dryRun)
|
|
1023
|
+
return { ...response, reconciliation: { transaction_id: null, targets: targets.length, recovered } };
|
|
1024
|
+
if (req.values.effect_sha256 !== effectSha256)
|
|
1025
|
+
reject({ class: "conflict", message: `TODO ${verb} batch effects changed after preview`, recovery: "Rerun the same batch input with --dry-run, review the new bounded effect, then use its exact effect SHA-256; no state was changed." });
|
|
1026
|
+
for (const { id } of transitionBatch.entries) {
|
|
1027
|
+
const record = reconciled.records.get(id);
|
|
1028
|
+
record.reconciliation = { ...record.reconciliation, transition_batch: { verb, input_sha256: inputSha256, effect_sha256: effectSha256, post_state_sha256: todoTransitionBatchPostStateSha256(record) } };
|
|
1029
|
+
const target = targets.find(({ path }) => path.endsWith(`/${id}.yaml`));
|
|
1030
|
+
target.after = canonicalEntityEnvelopeBytes({ id, artifact: "todo", record });
|
|
1031
|
+
}
|
|
1032
|
+
const transaction = publishTodoReconciliation(context, sourceRoot, todoBinding, targets, { updateBatchEffectSha256: effectSha256, interruptAfterTarget: options.interruptAfterTarget, beforeCommit: () => { assertState(pinnedRoot, sourceRoot, sourceBinding); const currentBinding = todoReconciliationBinding(req.projectRoot, sourceRoot); if (currentBinding.publicPath !== todoBinding.publicPath || currentBinding.mappingSha256 !== todoBinding.mappingSha256)
|
|
1033
|
+
reject({ class: "conflict", message: `TODO reconciliation mapping changed during ${verb} batch publication`, recovery: "Preserve the changed docs mapping and retry after every transaction target is restored; no mapping bytes were overwritten." }); } });
|
|
1034
|
+
context.assertValid();
|
|
1035
|
+
return { ...response, records: transitionBatch.entries.map(({ id }) => ({ id, record: reconciled.records.get(id) })), reconciliation: { transaction_id: transaction.id, targets: transaction.targetCount, recovered } };
|
|
1036
|
+
}
|
|
1037
|
+
if (batch) {
|
|
1038
|
+
if (!req.dryRun && (!req.values.confirmed || !SHA256.test(String(req.values.effect_sha256 ?? ""))))
|
|
1039
|
+
reject({ class: "invalid_request", message: "todo update batch apply requires its preview effect SHA-256 and --yes", recovery: "Run the same batch input with --dry-run, review it, then repeat that input with the returned --effect-sha256 value and --yes; no state was changed." });
|
|
1040
|
+
const requestedRecords = [];
|
|
1041
|
+
for (const { id, patch } of batch) {
|
|
1042
|
+
selectedById(todoEntities, "todo", id);
|
|
1043
|
+
const record = mutationRecord({ ...req, input: patch, values: { id }, callerPayload: patch }, reconciled.records.get(id), todoEntities.map((entity) => ({ ...entity, record: reconciled.records.get(entity.id) })));
|
|
1044
|
+
reconciled.records.set(id, record);
|
|
1045
|
+
requestedRecords.push({ id, record });
|
|
1046
|
+
}
|
|
1047
|
+
const referenceEntities = [...reconciled.records].map(([todoId, record]) => ({ boundary: TODO.boundary, id: todoId, record }));
|
|
1048
|
+
for (const [todoId, record] of reconciled.records) {
|
|
1049
|
+
const violations = recordViolations("todo", record, sourceRoot);
|
|
1050
|
+
if (violations.length)
|
|
1051
|
+
reject({ class: "schema_violation", message: "todo entity input is invalid", violations, recovery: "Correct the batch patch and preview the complete batch again; no state was changed." });
|
|
1052
|
+
if (record.readiness !== undefined)
|
|
1053
|
+
assertTodoReferences(todoId, record, referenceEntities);
|
|
1054
|
+
}
|
|
1055
|
+
const visibleRecords = new Map([...reconciled.records].filter(([todoId]) => reconciled.visible.has(todoId)));
|
|
1056
|
+
const rendered = renderManagedMarkdown(markdown, visibleRecords, rows);
|
|
1057
|
+
const activationBytesAfter = activation ? loadedActivation.bytes.toString("utf8") : todoReconciliationActivationBytes(scan.retainedLegacyRows);
|
|
1058
|
+
const activationAfter = activation ?? JSON.parse(activationBytesAfter);
|
|
1059
|
+
const finalRows = managedRows(rendered, activationAfter, todoEntities).rows;
|
|
1060
|
+
for (const [todoId, record] of reconciled.records) {
|
|
1061
|
+
const row = finalRows.get(todoId);
|
|
1062
|
+
reconciled.records.set(todoId, withBaseline(record, row ? rowSnapshot(row, record) : { present: false }));
|
|
1063
|
+
}
|
|
1064
|
+
const targets = todoEntities.map((entity) => ({ path: entity.relativePath, before: exactDiscoveredEntityBytes(entity), after: canonicalEntityEnvelopeBytes({ id: entity.id, artifact: "todo", record: reconciled.records.get(entity.id), migrationProvenance: entity.migrationProvenance ?? undefined }) }));
|
|
1065
|
+
if (activating)
|
|
1066
|
+
targets.push({ path: TODO_RECONCILIATION_ACTIVATION_PATH, before: null, after: activationBytesAfter });
|
|
1067
|
+
targets.push({ path: publicRelative, before: publicExists ? markdownBefore : null, after: rendered });
|
|
1068
|
+
const effectTargets = targets.filter((target) => target.before === null || !target.before.equals(Buffer.from(target.after))).sort((left, right) => Number(left.path === publicRelative) - Number(right.path === publicRelative) || left.path.localeCompare(right.path));
|
|
1069
|
+
const effectSha256 = todoUpdateBatchEffectSha256(req.input, todoBinding.mappingSha256, effectTargets.map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(target.before).digest("hex"), after_sha256: createHash("sha256").update(target.after).digest("hex") })));
|
|
1070
|
+
const applyCommand = `agentera state todo update --input <same-input> --effect-sha256 ${effectSha256} --yes --format json`;
|
|
1071
|
+
const response = { schemaVersion: "agentera.stateWrite.v1", command: "state todo update", status: "pass", artifact: "todo", records: requestedRecords.map(({ id }) => ({ id, record: reconciled.records.get(id) })), operation: { verb: "update", dry_run: req.dryRun, idempotent_replay: false }, validation: { status: "pass", violations: [] }, effect_sha256: effectSha256, apply_command: req.dryRun ? applyCommand : null };
|
|
1072
|
+
if (req.dryRun)
|
|
1073
|
+
return { ...response, reconciliation: { transaction_id: null, targets: targets.length, recovered } };
|
|
1074
|
+
if (req.values.effect_sha256 !== effectSha256)
|
|
1075
|
+
reject({ class: "conflict", message: "TODO update batch effects changed after preview", recovery: "Rerun the same batch input with --dry-run, review the new bounded effect, then use its exact effect SHA-256; no state was changed." });
|
|
1076
|
+
const transaction = publishTodoReconciliation(context, sourceRoot, todoBinding, targets, { updateBatchEffectSha256: effectSha256, interruptAfterTarget: options.interruptAfterTarget, beforeCommit: () => { assertState(pinnedRoot, sourceRoot, sourceBinding); const currentBinding = todoReconciliationBinding(req.projectRoot, sourceRoot); if (currentBinding.publicPath !== todoBinding.publicPath || currentBinding.mappingSha256 !== todoBinding.mappingSha256)
|
|
1077
|
+
reject({ class: "conflict", message: "TODO reconciliation mapping changed during batch publication", recovery: "Preserve the changed docs mapping and retry after every transaction target is restored; no mapping bytes were overwritten." }); } });
|
|
1078
|
+
context.assertValid();
|
|
1079
|
+
return { ...response, reconciliation: { transaction_id: transaction.id, targets: transaction.targetCount, recovered } };
|
|
1080
|
+
}
|
|
871
1081
|
let id;
|
|
872
1082
|
let requested;
|
|
873
1083
|
let selected;
|
|
@@ -3,10 +3,12 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { loadYamlMapping } from "../core/yaml.js";
|
|
5
5
|
import { canonicalRecordJson } from "./archiveDiscovery.js";
|
|
6
|
-
import { entityExactGetMaxBytes } from "./entityStorage.js";
|
|
6
|
+
import { canonicalEntityEnvelopeBytes, entityExactGetMaxBytes } from "./entityStorage.js";
|
|
7
7
|
import { FILE_REPLACEMENT_METADATA_NAME, FILE_REPLACEMENT_RECOVERY_VERSION, validateEntityRecoveryDirectory, } from "./entityPublicationContext.js";
|
|
8
8
|
import { ExactReplacementConflictError, FileReplacementError } from "./exactReplacementRecovery.js";
|
|
9
9
|
import { TODO_RECONCILIATION_ACTIVATION_PATH } from "./todoReconciliationActivation.js";
|
|
10
|
+
import { todoUpdateBatchEffectSha256 } from "./todoUpdateBatch.js";
|
|
11
|
+
import { todoCreateBatchEffectSha256 } from "./todoCreateBatch.js";
|
|
10
12
|
import { reject } from "./write/errors.js";
|
|
11
13
|
const VERSION = "agentera.todoReconciliationTransaction.v1";
|
|
12
14
|
const DIRECTORY = ".agentera/.todo-reconciliation";
|
|
@@ -62,7 +64,7 @@ function createReceiptFromTargets(targets) {
|
|
|
62
64
|
if (!created.length)
|
|
63
65
|
return undefined;
|
|
64
66
|
if (created.length !== 1)
|
|
65
|
-
|
|
67
|
+
return undefined;
|
|
66
68
|
const target = created[0];
|
|
67
69
|
const createdId = path.posix.basename(target.path, ".yaml");
|
|
68
70
|
let envelope;
|
|
@@ -99,7 +101,7 @@ function parseJournal(bytes, fileName) {
|
|
|
99
101
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
100
102
|
invalidJournal("TODO reconciliation journal is not a mapping");
|
|
101
103
|
const value = parsed;
|
|
102
|
-
const expectedKeys = ["schema_version", "id", "public_path", "mapping_sha256", "targets", ...(value.create === undefined ? [] : ["create"]), ...(value.activation_effect_sha256 === undefined ? [] : ["activation_effect_sha256"]), ...(value.owner_mapping_sha256 === undefined ? [] : ["owner_mapping_sha256"])].sort().join(",");
|
|
104
|
+
const expectedKeys = ["schema_version", "id", "public_path", "mapping_sha256", "targets", ...(value.create === undefined ? [] : ["create"]), ...(value.create_batch === undefined ? [] : ["create_batch"]), ...(value.activation_effect_sha256 === undefined ? [] : ["activation_effect_sha256"]), ...(value.owner_mapping_sha256 === undefined ? [] : ["owner_mapping_sha256"]), ...(value.update_batch_effect_sha256 === undefined ? [] : ["update_batch_effect_sha256"])].sort().join(",");
|
|
103
105
|
if (Object.keys(value).sort().join(",") !== expectedKeys
|
|
104
106
|
|| value.schema_version !== VERSION
|
|
105
107
|
|| typeof value.id !== "string"
|
|
@@ -115,6 +117,10 @@ function parseJournal(bytes, fileName) {
|
|
|
115
117
|
invalidJournal("TODO reconciliation journal has an invalid activation effect authorization");
|
|
116
118
|
if (value.owner_mapping_sha256 !== undefined && !/^[a-f0-9]{64}$/.test(value.owner_mapping_sha256))
|
|
117
119
|
invalidJournal("TODO reconciliation journal has an invalid owner-mapping authorization");
|
|
120
|
+
if (value.update_batch_effect_sha256 !== undefined && !/^[a-f0-9]{64}$/.test(value.update_batch_effect_sha256))
|
|
121
|
+
invalidJournal("TODO reconciliation journal has an invalid update-batch effect authorization");
|
|
122
|
+
if (value.create_batch !== undefined && (!value.create_batch || typeof value.create_batch !== "object" || Array.isArray(value.create_batch) || Object.keys(value.create_batch).sort().join(",") !== "effect_sha256,input_sha256,local_refs" || !/^[a-f0-9]{64}$/.test(value.create_batch.effect_sha256) || !/^[a-f0-9]{64}$/.test(value.create_batch.input_sha256) || !value.create_batch.local_refs || typeof value.create_batch.local_refs !== "object" || Array.isArray(value.create_batch.local_refs) || Object.entries(value.create_batch.local_refs).some(([ref, id]) => !/^[a-z][a-z0-9_-]{0,63}$/.test(ref) || typeof id !== "string" || !/^[a-z]{10}$/.test(id))))
|
|
123
|
+
invalidJournal("TODO reconciliation journal has an invalid create-batch receipt");
|
|
118
124
|
if (value.create !== undefined && (!value.create
|
|
119
125
|
|| typeof value.create !== "object"
|
|
120
126
|
|| Array.isArray(value.create)
|
|
@@ -151,14 +157,14 @@ function parseJournal(bytes, fileName) {
|
|
|
151
157
|
|| value.create.created_id !== inferredCreate.created_id
|
|
152
158
|
|| value.create.request_sha256 !== inferredCreate.request_sha256))
|
|
153
159
|
invalidJournal("TODO reconciliation create receipt does not match its canonical entity target");
|
|
154
|
-
const identity = value.create || value.activation_effect_sha256 || value.owner_mapping_sha256
|
|
155
|
-
? { ...(value.create ? { create: value.create } : {}), ...(value.activation_effect_sha256 ? { activation_effect_sha256: value.activation_effect_sha256 } : {}), ...(value.owner_mapping_sha256 ? { owner_mapping_sha256: value.owner_mapping_sha256 } : {}), targets: body }
|
|
160
|
+
const identity = value.create || value.create_batch || value.activation_effect_sha256 || value.owner_mapping_sha256 || value.update_batch_effect_sha256
|
|
161
|
+
? { ...(value.create ? { create: value.create } : {}), ...(value.create_batch ? { create_batch: value.create_batch } : {}), ...(value.activation_effect_sha256 ? { activation_effect_sha256: value.activation_effect_sha256 } : {}), ...(value.owner_mapping_sha256 ? { owner_mapping_sha256: value.owner_mapping_sha256 } : {}), ...(value.update_batch_effect_sha256 ? { update_batch_effect_sha256: value.update_batch_effect_sha256 } : {}), targets: body }
|
|
156
162
|
: body;
|
|
157
163
|
const expectedId = createHash("sha256").update(canonicalRecordJson(identity)).digest("hex").slice(0, 24);
|
|
158
164
|
if (value.id !== expectedId || (fileName !== undefined && fileName !== `${value.id}.json`)) {
|
|
159
165
|
invalidJournal("TODO reconciliation journal identity does not match its canonical targets");
|
|
160
166
|
}
|
|
161
|
-
const create = value.create ?? inferredCreate;
|
|
167
|
+
const create = value.create ?? (value.create_batch ? undefined : inferredCreate);
|
|
162
168
|
return { ...value, ...(create ? { create } : {}) };
|
|
163
169
|
}
|
|
164
170
|
function targetLimit(target, sourceRoot) {
|
|
@@ -446,6 +452,29 @@ export function recoverTodoReconciliation(context, sourceRoot, binding, options
|
|
|
446
452
|
const journalBytes = fs.readFileSync(path.join(root, relative));
|
|
447
453
|
const journal = parseJournal(journalBytes, name);
|
|
448
454
|
assertBinding(journal, binding);
|
|
455
|
+
if (journal.update_batch_effect_sha256) {
|
|
456
|
+
const supplied = options.updateBatch;
|
|
457
|
+
const transition = supplied && /^agentera\.todo(?:SetSeverity|Resolve)Batch\.v1$/.test(String(supplied.input.schema_version));
|
|
458
|
+
const requestEffectSha256 = supplied ? todoUpdateBatchEffectSha256(supplied.input, journal.mapping_sha256, journal.targets.map((target) => ({
|
|
459
|
+
path: target.path,
|
|
460
|
+
before_sha256: target.before === null ? null : createHash("sha256").update(decode(target.before)).digest("hex"),
|
|
461
|
+
after_sha256: createHash("sha256").update(transition && target.path.includes("/todo_item/") ? (() => { const value = loadYamlMapping(decode(target.after).toString("utf8")); const record = value.record; if (record.reconciliation && typeof record.reconciliation === "object" && !Array.isArray(record.reconciliation))
|
|
462
|
+
delete record.reconciliation.transition_batch; return canonicalEntityEnvelopeBytes({ id: String(value.id), artifact: "todo", record }); })() : decode(target.after)).digest("hex"),
|
|
463
|
+
}))) : null;
|
|
464
|
+
if (!supplied || supplied.effectSha256 !== journal.update_batch_effect_sha256 || requestEffectSha256 !== journal.update_batch_effect_sha256)
|
|
465
|
+
reject({
|
|
466
|
+
class: "conflict",
|
|
467
|
+
message: "pending TODO update batch does not match this request and effect authorization",
|
|
468
|
+
recovery: "Retry the exact original TODO update batch input with its preview effect SHA-256 and --yes; no transaction target bytes were changed.",
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (journal.create_batch) {
|
|
472
|
+
const supplied = options.createBatch;
|
|
473
|
+
const requestInputSha256 = supplied ? createHash("sha256").update(canonicalRecordJson(supplied.input)).digest("hex") : null;
|
|
474
|
+
const requestEffectSha256 = supplied ? todoCreateBatchEffectSha256(supplied.input, journal.mapping_sha256, journal.create_batch.local_refs, journal.targets.filter((target) => !(target.before === null && /^\.agentera\/entities\/todo\/todo_item\/[a-z]{10}\.yaml$/.test(target.path))).map((target) => ({ path: target.path, before_sha256: target.before === null ? null : createHash("sha256").update(decode(target.before)).digest("hex"), after_sha256: createHash("sha256").update(decode(target.after)).digest("hex") }))) : null;
|
|
475
|
+
if (!supplied || supplied.effectSha256 !== journal.create_batch.effect_sha256 || requestInputSha256 !== journal.create_batch.input_sha256 || requestEffectSha256 !== journal.create_batch.effect_sha256)
|
|
476
|
+
reject({ class: "conflict", message: "pending TODO create batch does not match this request and effect authorization", recovery: "Retry the exact original TODO create batch input with its preview effect SHA-256 and --yes; no transaction target bytes were changed." });
|
|
477
|
+
}
|
|
449
478
|
if (journal.create && options.createRequestSha256 !== journal.create.request_sha256)
|
|
450
479
|
reject({
|
|
451
480
|
class: "conflict",
|
|
@@ -486,6 +515,8 @@ export function recoverTodoReconciliation(context, sourceRoot, binding, options
|
|
|
486
515
|
transaction_id: journal.id,
|
|
487
516
|
target_count: journal.targets.length,
|
|
488
517
|
...(journal.create ? { create: journal.create } : {}),
|
|
518
|
+
...(journal.update_batch_effect_sha256 ? { update_batch_effect_sha256: journal.update_batch_effect_sha256 } : {}),
|
|
519
|
+
...(journal.create_batch ? { create_batch: journal.create_batch } : {}),
|
|
489
520
|
});
|
|
490
521
|
}
|
|
491
522
|
catch (error) {
|
|
@@ -512,8 +543,8 @@ export function publishTodoReconciliation(context, sourceRoot, binding, targets,
|
|
|
512
543
|
reject({ class: "schema_violation", message: "TODO activation authorization requires its activation target", recovery: "Recompute the complete activation target set from a fresh dry-run; no state was changed." });
|
|
513
544
|
if (options.ownerMappingSha256 && !options.activationEffectSha256)
|
|
514
545
|
reject({ class: "schema_violation", message: "TODO owner-mapping authorization requires its effect authorization", recovery: "Recompute the complete owner correction target set from a fresh dry-run; no state was changed." });
|
|
515
|
-
const identity = options.create || options.activationEffectSha256 || options.ownerMappingSha256
|
|
516
|
-
? { ...(options.create ? { create: options.create } : {}), ...(options.activationEffectSha256 ? { activation_effect_sha256: options.activationEffectSha256 } : {}), ...(options.ownerMappingSha256 ? { owner_mapping_sha256: options.ownerMappingSha256 } : {}), targets: body }
|
|
546
|
+
const identity = options.create || options.createBatch || options.activationEffectSha256 || options.ownerMappingSha256 || options.updateBatchEffectSha256
|
|
547
|
+
? { ...(options.create ? { create: options.create } : {}), ...(options.createBatch ? { create_batch: options.createBatch } : {}), ...(options.activationEffectSha256 ? { activation_effect_sha256: options.activationEffectSha256 } : {}), ...(options.ownerMappingSha256 ? { owner_mapping_sha256: options.ownerMappingSha256 } : {}), ...(options.updateBatchEffectSha256 ? { update_batch_effect_sha256: options.updateBatchEffectSha256 } : {}), targets: body }
|
|
517
548
|
: body;
|
|
518
549
|
const id = createHash("sha256").update(canonicalRecordJson(identity)).digest("hex").slice(0, 24);
|
|
519
550
|
if (!normalized.length)
|
|
@@ -544,8 +575,10 @@ export function publishTodoReconciliation(context, sourceRoot, binding, targets,
|
|
|
544
575
|
public_path: binding.publicPath,
|
|
545
576
|
mapping_sha256: binding.mappingSha256,
|
|
546
577
|
...(options.create ? { create: options.create } : {}),
|
|
578
|
+
...(options.createBatch ? { create_batch: options.createBatch } : {}),
|
|
547
579
|
...(options.activationEffectSha256 ? { activation_effect_sha256: options.activationEffectSha256 } : {}),
|
|
548
580
|
...(options.ownerMappingSha256 ? { owner_mapping_sha256: options.ownerMappingSha256 } : {}),
|
|
581
|
+
...(options.updateBatchEffectSha256 ? { update_batch_effect_sha256: options.updateBatchEffectSha256 } : {}),
|
|
549
582
|
targets: body,
|
|
550
583
|
};
|
|
551
584
|
const bytes = `${JSON.stringify(journal)}\n`;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalRecordJson } from "./archiveDiscovery.js";
|
|
3
|
+
import { reject } from "./write/errors.js";
|
|
4
|
+
const ID = /^[a-z]{10}$/;
|
|
5
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
6
|
+
const SEVERITIES = new Set(["critical", "degraded", "normal", "annoying"]);
|
|
7
|
+
export function todoTransitionBatchPostStateSha256(record) {
|
|
8
|
+
const value = structuredClone(record);
|
|
9
|
+
if (value.reconciliation && typeof value.reconciliation === "object" && !Array.isArray(value.reconciliation))
|
|
10
|
+
delete value.reconciliation.transition_batch;
|
|
11
|
+
return createHash("sha256").update(canonicalRecordJson(value)).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
export function matchesTodoTransitionBatchPostState(record, receipt, entry, verb) {
|
|
14
|
+
const lifecycle = record.lifecycle && typeof record.lifecycle === "object" && !Array.isArray(record.lifecycle) ? record.lifecycle : null;
|
|
15
|
+
return receipt.post_state_sha256 === todoTransitionBatchPostStateSha256(record)
|
|
16
|
+
&& lifecycle !== null
|
|
17
|
+
&& canonicalRecordJson(lifecycle) === canonicalRecordJson({ operation: verb, reason: entry.reason, date: entry.date })
|
|
18
|
+
&& (verb === "set-severity" ? record.severity === entry.severity : record.status === "resolved");
|
|
19
|
+
}
|
|
20
|
+
export function parseTodoTransitionBatch(input, verb) {
|
|
21
|
+
if (verb !== "set-severity" && verb !== "resolve")
|
|
22
|
+
return null;
|
|
23
|
+
const version = `agentera.todo${verb === "set-severity" ? "SetSeverity" : "Resolve"}Batch.v1`;
|
|
24
|
+
if (!input || input.schema_version !== version)
|
|
25
|
+
return null;
|
|
26
|
+
const member = verb === "set-severity" ? "transitions" : "resolutions";
|
|
27
|
+
const values = input[member];
|
|
28
|
+
const violations = [];
|
|
29
|
+
if (Object.keys(input).sort().join(",") !== ["schema_version", member].sort().join(",") || !Array.isArray(values))
|
|
30
|
+
violations.push(`batch envelope must contain exactly schema_version and ${member}`);
|
|
31
|
+
if (!Array.isArray(values) || values.length < 1 || values.length > 256)
|
|
32
|
+
violations.push(`${member} must contain 1 to 256 entries`);
|
|
33
|
+
const expected = verb === "set-severity" ? "date,id,reason,severity" : "date,id,reason";
|
|
34
|
+
const entries = (Array.isArray(values) ? values : []).map((value, index) => {
|
|
35
|
+
const item = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
36
|
+
if (Object.keys(item).sort().join(",") !== expected)
|
|
37
|
+
violations.push(`${member}[${index}] must contain exactly ${expected.split(",").join(", ")}`);
|
|
38
|
+
const id = String(item.id ?? "");
|
|
39
|
+
const reason = String(item.reason ?? "").trim();
|
|
40
|
+
const date = String(item.date ?? "");
|
|
41
|
+
const severity = item.severity === undefined ? undefined : String(item.severity);
|
|
42
|
+
if (!ID.test(id))
|
|
43
|
+
violations.push(`${member}[${index}].id must be a bare ten-letter TODO ID`);
|
|
44
|
+
if (!reason || [...reason].length > 500)
|
|
45
|
+
violations.push(`${member}[${index}].reason must contain 1 to 500 code points`);
|
|
46
|
+
const timestamp = Date.parse(`${date}T00:00:00Z`);
|
|
47
|
+
if (!DATE.test(date) || Number.isNaN(timestamp) || new Date(timestamp).toISOString().slice(0, 10) !== date)
|
|
48
|
+
violations.push(`${member}[${index}].date must be a valid YYYY-MM-DD date`);
|
|
49
|
+
if (verb === "set-severity" && !SEVERITIES.has(severity ?? ""))
|
|
50
|
+
violations.push(`${member}[${index}].severity is invalid`);
|
|
51
|
+
return { id, reason, date, ...(severity === undefined ? {} : { severity }) };
|
|
52
|
+
});
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const { id } of entries) {
|
|
55
|
+
if (id && seen.has(id))
|
|
56
|
+
violations.push(`duplicate ${verb} target '${id}'`);
|
|
57
|
+
seen.add(id);
|
|
58
|
+
}
|
|
59
|
+
if (violations.length)
|
|
60
|
+
reject({ class: "schema_violation", message: `todo ${verb} batch input is invalid`, violations, recovery: `Correct the strict ${version} envelope, then preview it again; no state was changed.` });
|
|
61
|
+
return { verb, entries };
|
|
62
|
+
}
|
|
63
|
+
export function todoTransitionBatchEffectSha256(input, mappingSha256, targets) {
|
|
64
|
+
return createHash("sha256").update(canonicalRecordJson({ input, mapping_sha256: mappingSha256, targets })).digest("hex");
|
|
65
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalRecordJson } from "./archiveDiscovery.js";
|
|
3
|
+
import { todoInputViolations } from "./todoDocsEntityValidation.js";
|
|
4
|
+
import { reject } from "./write/errors.js";
|
|
5
|
+
export const TODO_UPDATE_BATCH_VERSION = "agentera.todoUpdateBatch.v1";
|
|
6
|
+
const ID = /^[a-z]{10}$/;
|
|
7
|
+
function mapping(value) {
|
|
8
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
export function inspectTodoUpdateBatch(input) {
|
|
11
|
+
if (!input || input.schema_version !== TODO_UPDATE_BATCH_VERSION)
|
|
12
|
+
return null;
|
|
13
|
+
const strictEnvelope = Object.keys(input).sort().join(",") === "schema_version,updates" && Array.isArray(input.updates);
|
|
14
|
+
const violations = [];
|
|
15
|
+
if (Object.keys(input).sort().join(",") !== "schema_version,updates")
|
|
16
|
+
violations.push("batch envelope must contain exactly schema_version and updates");
|
|
17
|
+
if (!Array.isArray(input.updates) || input.updates.length < 1 || input.updates.length > 256)
|
|
18
|
+
violations.push("updates must contain 1 to 256 entries");
|
|
19
|
+
const updates = (Array.isArray(input.updates) ? input.updates : []).map((value, index) => {
|
|
20
|
+
if (!mapping(value) || Object.keys(value).sort().join(",") !== "id,patch" || !ID.test(String(value.id ?? "")) || !mapping(value.patch)) {
|
|
21
|
+
violations.push(`updates[${index}] must contain exactly one bare id and one patch mapping`);
|
|
22
|
+
return { id: "", patch: {} };
|
|
23
|
+
}
|
|
24
|
+
violations.push(...todoInputViolations(value.patch, "update").map((item) => `updates[${index}].patch: ${item}`));
|
|
25
|
+
return { id: String(value.id), patch: value.patch };
|
|
26
|
+
});
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const { id } of updates) {
|
|
29
|
+
if (id && seen.has(id))
|
|
30
|
+
violations.push(`duplicate update target '${id}'`);
|
|
31
|
+
seen.add(id);
|
|
32
|
+
}
|
|
33
|
+
return { strictEnvelope, updates, violations };
|
|
34
|
+
}
|
|
35
|
+
export function parseTodoUpdateBatch(input) {
|
|
36
|
+
const inspected = inspectTodoUpdateBatch(input);
|
|
37
|
+
if (!inspected)
|
|
38
|
+
return null;
|
|
39
|
+
if (inspected.violations.length)
|
|
40
|
+
reject({ class: "schema_violation", message: "todo update batch input is invalid", violations: inspected.violations, recovery: "Correct the strict agentera.todoUpdateBatch.v1 envelope, then preview it again; no state was changed." });
|
|
41
|
+
return inspected.updates;
|
|
42
|
+
}
|
|
43
|
+
export function todoUpdateBatchEffectSha256(input, mappingSha256, targets) {
|
|
44
|
+
return createHash("sha256").update(canonicalRecordJson({
|
|
45
|
+
schema_version: TODO_UPDATE_BATCH_VERSION,
|
|
46
|
+
input,
|
|
47
|
+
mapping_sha256: mappingSha256,
|
|
48
|
+
targets,
|
|
49
|
+
})).digest("hex");
|
|
50
|
+
}
|
|
@@ -300,14 +300,17 @@ function decisionsGuidance(artifact, verb, _entityHealth = false, entityArtifact
|
|
|
300
300
|
return ["a bare ten-letter objective ID is assigned by the CLI; do not pass an identity", ...base];
|
|
301
301
|
if (entityArtifact && artifact === "todo" && verb === "update")
|
|
302
302
|
return [
|
|
303
|
-
"select one TODO item with its bare ten-letter --id; numeric, prefixed, composite, alias, and path identities are unavailable",
|
|
304
|
-
"the input document is a patch: omitted fields preserve state and only target_version, requirements, acceptance, and readiness accept typed clears",
|
|
303
|
+
"for a singleton, select one TODO item with its bare ten-letter --id; numeric, prefixed, composite, alias, and path identities are unavailable",
|
|
304
|
+
"for a singleton, the input document is a patch: omitted fields preserve state and only target_version, requirements, acceptance, and readiness accept typed clears",
|
|
305
|
+
"for a batch, omit --id and supply one strict agentera.todoUpdateBatch.v1 envelope; preview it with --dry-run before exact confirmed apply",
|
|
305
306
|
"public fields are TODO.md-owned; readiness, dependencies, gates, and evidence are Agentera-owned",
|
|
306
307
|
...base,
|
|
307
308
|
];
|
|
308
309
|
if (entityArtifact && artifact === "todo" && ["set-severity", "supersede", "resolve", "reopen"].includes(verb))
|
|
309
310
|
return [
|
|
310
|
-
"
|
|
311
|
+
["set-severity", "resolve"].includes(verb)
|
|
312
|
+
? `for a singleton, select one TODO item with its bare ten-letter --id and use only flags; for a batch, omit --id and supply the strict ${verb === "set-severity" ? "agentera.todoSetSeverityBatch.v1" : "agentera.todoResolveBatch.v1"} envelope through --input`
|
|
313
|
+
: "select one TODO item with its bare ten-letter --id; lifecycle transitions are flag-only and accept no --input record",
|
|
311
314
|
"supply a reason and YYYY-MM-DD date; supersede additionally requires a distinct existing replacement ID",
|
|
312
315
|
...base,
|
|
313
316
|
];
|
|
@@ -51,11 +51,11 @@ const RUNTIME_OPERATION_CORES = [
|
|
|
51
51
|
op("todo", "activate", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["reconciliation", "public_document", "activation"], compacts: true }),
|
|
52
52
|
op("todo", "repair", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["reconciliation", "public_document", "activation"], compacts: true }),
|
|
53
53
|
op("todo", "correct-owners", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["reconciliation", "public_document", "activation"], inputMode: "structured", inputRoot: "one unsafe TODO owner mapping", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], inputMaxBytes: 32768, compacts: true }),
|
|
54
|
-
op("todo", "create", [], { ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "full typed TODO record", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
|
|
55
|
-
op("todo", "update", [f("--id", "id", "string", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "TODO record patch", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
|
|
56
|
-
op("todo", "set-severity", [f("--id", "id", "string", { required: true }), f("--severity", "severity", "string", { required: true, validValues: ["critical", "degraded", "normal", "annoying"] }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "severity", "lifecycle"] }),
|
|
54
|
+
op("todo", "create", [f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "full typed TODO record or agentera.todoCreateBatch.v1 envelope", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
|
|
55
|
+
op("todo", "update", [f("--id", "id", "string", { required: true }), f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMode: "structured", inputRoot: "TODO record patch or agentera.todoUpdateBatch.v1 envelope", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact", "status", "public_order", "lifecycle"], inputMaxBytes: 32768 }),
|
|
56
|
+
op("todo", "set-severity", [f("--id", "id", "string", { required: true }), f("--severity", "severity", "string", { required: true, validValues: ["critical", "degraded", "normal", "annoying"] }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true }), f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { selectors: ["--id"], ownedFields: ["id", "artifact", "severity", "lifecycle"], inputMode: "structured", inputRoot: "agentera.todoSetSeverityBatch.v1 envelope", inputOptional: true, inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], inputMaxBytes: 32768 }),
|
|
57
57
|
op("todo", "supersede", [f("--id", "id", "string", { required: true }), f("--replacement", "lifecycle.replacement", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"] }),
|
|
58
|
-
op("todo", "resolve", [f("--id", "id", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"] }),
|
|
58
|
+
op("todo", "resolve", [f("--id", "id", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true }), f("--effect-sha256", "effect_sha256", "string"), f("--yes", "confirmed", "boolean")], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"], inputMode: "structured", inputRoot: "agentera.todoResolveBatch.v1 envelope", inputOptional: true, inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], inputMaxBytes: 32768 }),
|
|
59
59
|
op("todo", "reopen", [f("--id", "id", "string", { required: true }), f("--reason", "lifecycle.reason", "string", { required: true }), f("--date", "lifecycle.date", "date", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact", "status", "lifecycle"] }),
|
|
60
60
|
op("docs", "create", [], { ownedFields: ["id", "artifact"], inputMode: "structured", inputRoot: "one documentation inventory entry", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact"], inputMaxBytes: 32768 }),
|
|
61
61
|
op("docs", "update", [f("--id", "id", "string", { required: true })], { selectors: ["--id"], ownedFields: ["id", "artifact"], inputMode: "structured", inputRoot: "one documentation inventory entry", inputSources: ["file", "stdin"], structuredInputSources: ["file", "stdin"], cliOwnedFields: ["id", "artifact"], inputMaxBytes: 32768 }),
|
|
@@ -99,11 +99,11 @@ const RUNTIME_OPERATION_PROJECTIONS = {
|
|
|
99
99
|
"todo.activate": projection("Preview and review every reported safe activation effect before explicit confirmed apply; unsafe inactive evidence requires the separate effect-bound owner-correction operation.", developmentCommand("state todo activate --dry-run --format json"), developmentCommand("state todo activate --effect-sha256 EFFECT_SHA256 --yes --format json")),
|
|
100
100
|
"todo.repair": projection("Preview and review every diagnosed repair decision before explicit confirmed apply; ambiguous evidence is rejected without effects.", developmentCommand("state todo repair --dry-run --format json"), developmentCommand("state todo repair --effect-sha256 EFFECT_SHA256 --yes --format json")),
|
|
101
101
|
"todo.correct-owners": projection("Supply one complete id/source_line owner mapping, preview its bounded effect, then apply only the exact returned effect; malformed, ambiguous, unmatched, or stale evidence is rejected without effects.", developmentCommand("state todo correct-owners --input owner-mapping.yaml --dry-run --format json"), developmentCommand("state todo correct-owners --input owner-mapping.yaml --effect-sha256 EFFECT_SHA256 --yes --format json")),
|
|
102
|
-
"todo.create": projection(
|
|
103
|
-
"todo.update": projection("
|
|
104
|
-
"todo.set-severity": projection("Use
|
|
102
|
+
"todo.create": projection("For one item, preserve the existing full-record form. For a batch, supply agentera.todoCreateBatch.v1, preview with --dry-run, then repeat the same input with its effect SHA-256 and --yes.", developmentCommand("state todo create --input todo.yaml --format json")),
|
|
103
|
+
"todo.update": projection("For one item, preserve the existing --id and patch form. For a batch, supply agentera.todoUpdateBatch.v1, preview with --dry-run, then repeat the same input with its effect SHA-256 and --yes.", developmentCommand("state todo update --id qjtrmnpvka --input todo-patch.yaml --format json")),
|
|
104
|
+
"todo.set-severity": projection("Use singleton flags unchanged, or supply agentera.todoSetSeverityBatch.v1 through --input, preview it, then exact-apply it with the effect SHA-256 and --yes.", developmentCommand('state todo set-severity --id qjtrmnpvka --severity degraded --reason "Impact changed" --date 2026-07-31 --format json')),
|
|
105
105
|
"todo.supersede": projection("Use the selected bare TODO ID, an existing distinct replacement ID, a reason, and a YYYY-MM-DD date; no record input is accepted.", developmentCommand('state todo supersede --id qjtrmnpvka --replacement zqtrmnpvka --reason "Replaced by narrower work" --date 2026-07-31 --format json')),
|
|
106
|
-
"todo.resolve": projection("Use
|
|
106
|
+
"todo.resolve": projection("Use singleton flags unchanged, or supply agentera.todoResolveBatch.v1 through --input, preview it, then exact-apply it with the effect SHA-256 and --yes.", developmentCommand('state todo resolve --id qjtrmnpvka --reason "Shipped" --date 2026-07-31 --format json')),
|
|
107
107
|
"todo.reopen": projection("Use the bare resolved TODO ID, a reason, and a YYYY-MM-DD date; no record input is accepted.", developmentCommand('state todo reopen --id qjtrmnpvka --reason "Scope returned" --date 2026-07-31 --format json')),
|
|
108
108
|
"docs.create": projection("Remove id and artifact from the input and retry with one schema-valid documentation inventory entry.", developmentCommand("state docs create --input documentation.yaml --format json")),
|
|
109
109
|
"docs.update": projection("Reread the documentation entry, copy its bare ID to --id, remove CLI-owned fields, and retry.", developmentCommand("state docs update --id qjtrmnpvka --input documentation.yaml --format json")),
|