agentera 3.0.0-dev.47 → 3.0.0-dev.51
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 +15 -4
- package/bundle/CHANGELOG.md +4 -4
- package/bundle/references/adapters/package-publication.json +4 -4
- package/bundle/references/adapters/package-registry.yaml +2 -1
- package/bundle/references/artifacts/state-storage-authority.yaml +51 -11
- package/bundle/skills/agentera/SKILL.md +11 -2
- package/bundle/skills/agentera/schemas/artifacts/plan.yaml +38 -4
- package/dist/capabilities/orchestrate/instructions.js +2 -0
- package/dist/capabilities/plan/instructions.js +1 -0
- package/dist/capabilities/status/instructions.js +5 -1
- package/dist/cli/commands/compact.js +125 -3
- package/dist/cli/commands/doctor.js +8 -1
- package/dist/cli/commands/prime/collectEntityOrientation.js +22 -7
- package/dist/cli/commands/prime/collectOrientationState.js +3 -3
- package/dist/cli/commands/prime/orientationOutput.js +2 -0
- package/dist/cli/commands/state/write.js +1 -5
- package/dist/cli/commands/validate.js +1 -1
- package/dist/cli/help.js +4 -0
- package/dist/cli/orientation/attention.js +4 -2
- package/dist/core/developmentInvocation.js +1 -1
- package/dist/registries/activationTuples.js +3 -2
- package/dist/registries/packagePublication.js +2 -2
- package/dist/state/entityStorage.js +9 -6
- package/dist/state/planEntities.js +485 -33
- package/dist/state/planLineageValidation.js +52 -0
- package/dist/state/planReplacementTransaction.js +475 -0
- package/dist/state/todoActivationSafety.js +58 -0
- package/dist/state/todoDocsEntities.js +22 -17
- package/dist/state/todoReconciliationActivation.js +12 -4
- package/dist/state/todoReconciliationInspection.js +8 -12
- package/dist/state/write/explain.js +32 -1
- package/dist/state/write/grammar.js +5 -0
- package/dist/state/write/operations.js +1 -0
- package/dist/state/write/runtimeOperations.js +6 -4
- package/package.json +2 -2
|
@@ -5,7 +5,8 @@ import { resolveSourceRoot } from "../core/sourceRoot.js";
|
|
|
5
5
|
import { dumpYamlMapping } from "../core/yaml.js";
|
|
6
6
|
import { canonicalRecordJson } from "./archiveDiscovery.js";
|
|
7
7
|
import { StateRetrievalFailure } from "./directRetrieval.js";
|
|
8
|
-
import { allocateEntityId, entityExactGetMaxBytes, exactDiscoveredEntityBytes, publishEntity, replaceEntity, replaceEntityUnderLock, validateEntityDiscovery, validateEntityState, withEntityWriterLock } from "./entityStorage.js";
|
|
8
|
+
import { allocateEntityId, canonicalEntityEnvelopeBytes, entityExactGetMaxBytes, exactDiscoveredEntityBytes, publishEntity, replaceEntity, replaceEntityUnderLock, validateEntityDiscovery, validateEntityState, withEntityWriterLock } from "./entityStorage.js";
|
|
9
|
+
import { inspectPendingPlanReplacement, publishPlanReplacement, recoverPendingPlanReplacement } from "./planReplacementTransaction.js";
|
|
9
10
|
import { detectStateModeBinding } from "./stateMode.js";
|
|
10
11
|
import { normalizeAndValidatePlanCreateInput, validatePlanPublicationCandidate } from "./write/planPublication.js";
|
|
11
12
|
import { reject } from "./write/errors.js";
|
|
@@ -13,6 +14,7 @@ import { mutatePlanTaskEvaluation, planTaskRecordViolations } from "./write/plan
|
|
|
13
14
|
import { loadStateStorageAuthority } from "./stateStorageAuthority.js";
|
|
14
15
|
import { entityListSelectorFlags, entityListSelectorKey, projectEntityList, resolveEntityListSelector } from "./entityListProjection.js";
|
|
15
16
|
import { shellQuoteArgument } from "../core/shell.js";
|
|
17
|
+
import { preCutoverCommand } from "../cli/preCutoverCommand.js";
|
|
16
18
|
const ARTIFACT = "plan";
|
|
17
19
|
const PLAN = "plan";
|
|
18
20
|
const TASK = "plan_task";
|
|
@@ -34,16 +36,39 @@ function contract(sourceRoot = resolveSourceRoot()) {
|
|
|
34
36
|
throw new Error(`invalid plan entity ${field} authority`);
|
|
35
37
|
return result;
|
|
36
38
|
};
|
|
39
|
+
const grammar = mapping(authority.mutation_grammar) ? authority.mutation_grammar : {};
|
|
40
|
+
const operations = Array.isArray(grammar.operations) ? grammar.operations : [];
|
|
41
|
+
const lifecycleLimits = ["create", "archive"].map((verb) => {
|
|
42
|
+
const operation = operations.find((value) => mapping(value) && value.artifact === ARTIFACT && value.verb === verb);
|
|
43
|
+
const bounds = mapping(operation) && mapping(operation.bounds) ? operation.bounds : {};
|
|
44
|
+
return number(bounds.max_collection_items, `plan ${verb} max_collection_items`);
|
|
45
|
+
});
|
|
46
|
+
if (new Set(lifecycleLimits).size !== 1)
|
|
47
|
+
throw new Error("invalid plan lifecycle max_collection_items authority");
|
|
37
48
|
if (typeof storage.canonical_root !== "string")
|
|
38
49
|
throw new Error(`invalid plan entity authority '${authorityPath}'`);
|
|
39
|
-
return { authorityPath, entityRoot: storage.canonical_root, defaultLimit: number(retrieval.default_limit, "default_limit"), maximumLimit: number(retrieval.maximum_limit, "maximum_limit"), maxUtf8Bytes: number(retrieval.max_utf8_bytes, "max_utf8_bytes") };
|
|
50
|
+
return { authorityPath, entityRoot: storage.canonical_root, defaultLimit: number(retrieval.default_limit, "default_limit"), maximumLimit: number(retrieval.maximum_limit, "maximum_limit"), maxUtf8Bytes: number(retrieval.max_utf8_bytes, "max_utf8_bytes"), openPlanConflictLimit: lifecycleLimits[0] };
|
|
40
51
|
}
|
|
41
|
-
function failure(kind, message, recovery, id) {
|
|
52
|
+
function failure(kind, message, recovery, id, details) {
|
|
42
53
|
const exampleId = id && ID.test(id) ? id : "qjtrmnpvka";
|
|
43
|
-
return new StateRetrievalFailure({ schemaVersion: "agentera.stateFailure.v1", status: "fail", error: { class: kind, message, syntax: "agentera state plan get --id ID --format json", example: `agentera state plan get --id ${exampleId} --format json`, recovery, artifact: ARTIFACT, ...(id ? { id } : {}) } }, kind === "invalid_request" ? 2 : 1);
|
|
54
|
+
return new StateRetrievalFailure({ schemaVersion: "agentera.stateFailure.v1", status: "fail", error: { class: kind, message, syntax: "agentera state plan get --id ID --format json", example: `agentera state plan get --id ${exampleId} --format json`, recovery, artifact: ARTIFACT, ...(id ? { id } : {}), ...(details ? { details } : {}) } }, kind === "invalid_request" ? 2 : 1);
|
|
44
55
|
}
|
|
45
56
|
function relative(root, file) { return path.relative(path.resolve(root), file).split(path.sep).join("/"); }
|
|
46
57
|
function all(root, sourceRoot, discovery) {
|
|
58
|
+
try {
|
|
59
|
+
const pending = inspectPendingPlanReplacement(root);
|
|
60
|
+
if (pending) {
|
|
61
|
+
const recovery = pending.kind === "existing"
|
|
62
|
+
? `Retry agentera state plan replace --predecessor ${pending.predecessor} --successor ${pending.successor} --format json to recover the exact pending replacement.`
|
|
63
|
+
: `Retry the exact original agentera state plan replace --predecessor ${pending.predecessor} --input PLAN.yaml --format json request to recover the pending replacement.`;
|
|
64
|
+
throw failure("unsupported_state", `plan replacement from '${pending.predecessor}' to '${pending.successor}' is pending durable recovery`, recovery, pending.predecessor);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof StateRetrievalFailure)
|
|
69
|
+
throw error;
|
|
70
|
+
throw failure("unsupported_state", `plan replacement recovery state is unavailable: ${error.message}`, "Preserve '.agentera/.entity-recovery/plan-replacement', restore its last valid journal bytes, then retry the exact plan replacement.");
|
|
71
|
+
}
|
|
47
72
|
const discovered = discovery
|
|
48
73
|
? validateEntityDiscovery(root, sourceRoot, discovery)
|
|
49
74
|
: validateEntityState(root, sourceRoot);
|
|
@@ -57,7 +82,24 @@ function all(root, sourceRoot, discovery) {
|
|
|
57
82
|
return relevant;
|
|
58
83
|
}
|
|
59
84
|
function planStatus(entity) { return String(mapping(entity.record?.header) ? entity.record.header.status ?? "" : entity.record?.status ?? ""); }
|
|
60
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Report competing canonical plans without inferring replacement roles from
|
|
87
|
+
* list order. The targeted replacement command remains the one recovery seam.
|
|
88
|
+
*/
|
|
89
|
+
export function openPlanConflictDiagnostic(openIds, sourceRoot = resolveSourceRoot()) {
|
|
90
|
+
const allIds = [...openIds].sort();
|
|
91
|
+
const sampleIds = allIds.slice(0, contract(sourceRoot).openPlanConflictLimit);
|
|
92
|
+
const omittedCount = allIds.length - sampleIds.length;
|
|
93
|
+
return {
|
|
94
|
+
message: `multiple open plans exist: ${sampleIds.join(", ")} (total=${allIds.length}, omitted=${omittedCount}); canonical state does not assign predecessor or successor roles`,
|
|
95
|
+
details: { open_plan_candidates: { total: allIds.length, sample_ids: sampleIds, omitted_count: omittedCount } },
|
|
96
|
+
recovery: preCutoverCommand("state plan replace --predecessor PREDECESSOR_ID --successor SUCCESSOR_ID --format json"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function multipleOpenPlanConflict(open, sourceRoot) {
|
|
100
|
+
return openPlanConflictDiagnostic(open.map((entity) => entity.id), sourceRoot);
|
|
101
|
+
}
|
|
102
|
+
function selectedPlan(entities, requested, sourceRoot = resolveSourceRoot()) {
|
|
61
103
|
if (requested !== undefined && !ID.test(requested))
|
|
62
104
|
throw failure("invalid_request", `plan ID '${requested}' must be ten lowercase letters`, "Use a bare plan ID returned by plan create or list.", requested);
|
|
63
105
|
const plans = entities.filter((entity) => entity.boundary === PLAN);
|
|
@@ -72,7 +114,94 @@ function selectedPlan(entities, requested) {
|
|
|
72
114
|
return open[0];
|
|
73
115
|
if (open.length === 0)
|
|
74
116
|
throw failure("not_found", "no open plan exists", "Create a plan, or use agentera state plan get --id ID for completed plan detail.");
|
|
75
|
-
|
|
117
|
+
const conflict = multipleOpenPlanConflict(open, sourceRoot);
|
|
118
|
+
throw failure("ambiguous", conflict.message, conflict.recovery, undefined, conflict.details);
|
|
119
|
+
}
|
|
120
|
+
function archivedPlanRecord(plan) {
|
|
121
|
+
const record = structuredClone(plan.record);
|
|
122
|
+
const header = mapping(record.header) ? record.header : {};
|
|
123
|
+
header.status = "archived";
|
|
124
|
+
record.header = header;
|
|
125
|
+
return record;
|
|
126
|
+
}
|
|
127
|
+
function preservedPlanEffects(plan) {
|
|
128
|
+
return {
|
|
129
|
+
id: plan.id,
|
|
130
|
+
from_status: planStatus(plan),
|
|
131
|
+
to_status: "archived",
|
|
132
|
+
preserved: ["task_records", "task_evaluations", "task_completion"],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function planLifecycleDecision(entities, intent, sourceRoot) {
|
|
136
|
+
const plans = entities.filter((entity) => entity.boundary === PLAN);
|
|
137
|
+
const open = plans.filter((entity) => planStatus(entity) === "open");
|
|
138
|
+
if (intent.verb === "create") {
|
|
139
|
+
if (open.length > 1) {
|
|
140
|
+
const conflict = multipleOpenPlanConflict(open, sourceRoot);
|
|
141
|
+
reject({
|
|
142
|
+
class: "conflict",
|
|
143
|
+
message: `${conflict.message}; implicit plan create cannot choose a predecessor`,
|
|
144
|
+
diagnosis: conflict.details,
|
|
145
|
+
recovery: conflict.recovery,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (open.length === 1) {
|
|
149
|
+
const predecessor = open[0];
|
|
150
|
+
if (!intent.force) {
|
|
151
|
+
reject({
|
|
152
|
+
class: "conflict",
|
|
153
|
+
message: `open plan '${predecessor.id}' blocks plan create`,
|
|
154
|
+
recovery: `Use agentera state plan create --force --input PLAN.yaml --format json only to archive '${predecessor.id}' unchanged and publish a successor.`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
predecessor,
|
|
159
|
+
archivedRecord: archivedPlanRecord(predecessor),
|
|
160
|
+
replay: false,
|
|
161
|
+
effects: {
|
|
162
|
+
lifecycle: "forced_replacement",
|
|
163
|
+
force: true,
|
|
164
|
+
archived_predecessor: preservedPlanEffects(predecessor),
|
|
165
|
+
successor_lineage: { field: "previous_plan_archived", predecessor: predecessor.id },
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const completed = plans.filter((entity) => planStatus(entity) === "complete");
|
|
170
|
+
const predecessor = completed.length === 1 ? completed[0] : undefined;
|
|
171
|
+
return {
|
|
172
|
+
...(predecessor ? { predecessor, archivedRecord: archivedPlanRecord(predecessor) } : {}),
|
|
173
|
+
replay: false,
|
|
174
|
+
effects: {
|
|
175
|
+
lifecycle: predecessor ? "completed_predecessor_replacement" : "create",
|
|
176
|
+
force: intent.force,
|
|
177
|
+
...(predecessor ? {
|
|
178
|
+
archived_predecessor: preservedPlanEffects(predecessor),
|
|
179
|
+
successor_lineage: { field: "previous_plan_archived", predecessor: predecessor.id },
|
|
180
|
+
} : {}),
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const target = selectedPlan(entities, intent.plan, sourceRoot);
|
|
185
|
+
const status = planStatus(target);
|
|
186
|
+
if (status === "open" && !intent.force) {
|
|
187
|
+
reject({
|
|
188
|
+
class: "conflict",
|
|
189
|
+
message: `open plan '${target.id}' cannot be archived without --force`,
|
|
190
|
+
recovery: `Complete '${target.id}', or use agentera state plan archive --plan ${target.id} --force --format json to preserve unfinished task history without claiming completion.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const archivedRecord = archivedPlanRecord(target);
|
|
194
|
+
const replay = canonicalRecordJson(archivedRecord) === canonicalRecordJson(target.record);
|
|
195
|
+
return {
|
|
196
|
+
target,
|
|
197
|
+
archivedRecord,
|
|
198
|
+
replay,
|
|
199
|
+
effects: {
|
|
200
|
+
lifecycle: status === "open" ? "forced_archive" : "archive",
|
|
201
|
+
force: intent.force,
|
|
202
|
+
archived_plan: preservedPlanEffects(target),
|
|
203
|
+
},
|
|
204
|
+
};
|
|
76
205
|
}
|
|
77
206
|
function taskFor(entities, id, plan) {
|
|
78
207
|
if (!ID.test(id))
|
|
@@ -100,6 +229,12 @@ function newId(root, sourceRoot, reserved, candidate) {
|
|
|
100
229
|
}
|
|
101
230
|
throw new Error("could not allocate unique plan entity IDs");
|
|
102
231
|
}
|
|
232
|
+
function preparedPlanCreateInput(req) {
|
|
233
|
+
const input = structuredClone(req.input ?? {});
|
|
234
|
+
normalizeAndValidatePlanCreateInput(input);
|
|
235
|
+
validatePlanPublicationCandidate(dumpYamlMapping(input));
|
|
236
|
+
return input;
|
|
237
|
+
}
|
|
103
238
|
export function createPlanEntities(req, options = {}) {
|
|
104
239
|
if (!options.publicationContext) {
|
|
105
240
|
const binding = detectStateModeBinding(req.projectRoot, options.sourceRoot);
|
|
@@ -116,9 +251,11 @@ export function createPlanEntities(req, options = {}) {
|
|
|
116
251
|
}
|
|
117
252
|
function createPlanEntitiesUnderLock(req, options) {
|
|
118
253
|
const sourceRoot = options.sourceRoot ?? resolveSourceRoot();
|
|
119
|
-
const input =
|
|
120
|
-
|
|
121
|
-
|
|
254
|
+
const input = preparedPlanCreateInput(req);
|
|
255
|
+
const discovery = validateEntityState(options.publicationContext.pinnedPath(), sourceRoot, { kind: "project", projectRoot: options.publicationContext.validatedRoot });
|
|
256
|
+
const entities = all(options.publicationContext.pinnedPath(), sourceRoot, discovery);
|
|
257
|
+
const lifecycle = options.lifecycle ?? planLifecycleDecision(entities, { verb: "create", force: req.force }, sourceRoot);
|
|
258
|
+
const command = options.command ?? "state plan create";
|
|
122
259
|
const tasks = Array.isArray(input.tasks) ? input.tasks.filter(mapping) : [];
|
|
123
260
|
const reserved = new Set();
|
|
124
261
|
const planId = newId(options.publicationContext?.pinnedPath() ?? req.projectRoot, sourceRoot, reserved, options.candidate);
|
|
@@ -127,6 +264,7 @@ function createPlanEntitiesUnderLock(req, options) {
|
|
|
127
264
|
const planRecord = structuredClone(input);
|
|
128
265
|
delete planRecord.tasks;
|
|
129
266
|
delete planRecord.previous_plan_archived;
|
|
267
|
+
delete planRecord.replacement_input_sha256;
|
|
130
268
|
const header = mapping(planRecord.header) ? planRecord.header : {};
|
|
131
269
|
delete header.id;
|
|
132
270
|
planRecord.header = header;
|
|
@@ -134,6 +272,10 @@ function createPlanEntitiesUnderLock(req, options) {
|
|
|
134
272
|
header.status = "open";
|
|
135
273
|
if (header.status === "completed")
|
|
136
274
|
header.status = "complete";
|
|
275
|
+
if (lifecycle.predecessor)
|
|
276
|
+
planRecord.previous_plan_archived = lifecycle.predecessor.id;
|
|
277
|
+
if (lifecycle.replacementInputSha256)
|
|
278
|
+
planRecord.replacement_input_sha256 = lifecycle.replacementInputSha256;
|
|
137
279
|
const taskRecords = tasks.map((task, index) => {
|
|
138
280
|
const record = structuredClone(task);
|
|
139
281
|
delete record.number;
|
|
@@ -151,23 +293,36 @@ function createPlanEntitiesUnderLock(req, options) {
|
|
|
151
293
|
});
|
|
152
294
|
const publications = [{ boundary: PLAN, id: planId, record: planRecord }, ...taskRecords.map((record, index) => ({ boundary: TASK, id: taskIds[index], record }))];
|
|
153
295
|
if (req.dryRun)
|
|
154
|
-
return envelope(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
296
|
+
return envelope(command, { id: planId, path: entityPath(req.projectRoot, sourceRoot, PLAN, planId), replay: false }, planRecord, true, { tasks: taskRecords.map((record, index) => ({ id: taskIds[index], artifact: ARTIFACT, record })), effects: lifecycle.effects });
|
|
297
|
+
if (command === "state plan replace" && lifecycle.predecessor && lifecycle.archivedRecord && lifecycle.replacementInputSha256) {
|
|
298
|
+
const targets = [
|
|
299
|
+
{
|
|
300
|
+
path: relative(req.projectRoot, lifecycle.predecessor.path),
|
|
301
|
+
before: exactDiscoveredEntityBytes(lifecycle.predecessor),
|
|
302
|
+
after: canonicalEntityEnvelopeBytes({ id: lifecycle.predecessor.id, artifact: ARTIFACT, record: lifecycle.archivedRecord, migrationProvenance: lifecycle.predecessor.migrationProvenance ?? undefined }),
|
|
303
|
+
},
|
|
304
|
+
...publications.map((item) => ({
|
|
305
|
+
path: relative(req.projectRoot, entityPath(req.projectRoot, sourceRoot, item.boundary, item.id)),
|
|
306
|
+
before: null,
|
|
307
|
+
after: canonicalEntityEnvelopeBytes({ id: item.id, artifact: ARTIFACT, record: item.record }),
|
|
308
|
+
})),
|
|
309
|
+
];
|
|
310
|
+
publishPlanReplacement(options.publicationContext, sourceRoot, { kind: "create", predecessor: lifecycle.predecessor.id, successor: planId, inputSha256: lifecycle.replacementInputSha256 }, targets, {
|
|
311
|
+
validate: () => {
|
|
312
|
+
const validation = validateEntityState(options.publicationContext.pinnedPath(), sourceRoot, { kind: "project", projectRoot: options.publicationContext.validatedRoot });
|
|
313
|
+
if (!validation.valid)
|
|
314
|
+
throw new Error(`targeted plan replacement graph failed state validation: ${validation.issues.map(({ message }) => message).join("; ")}`);
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
return envelope(command, { id: planId, path: entityPath(req.projectRoot, sourceRoot, PLAN, planId), replay: false }, planRecord, false, { tasks: taskRecords.map((record, index) => ({ id: taskIds[index], artifact: ARTIFACT, record })), effects: lifecycle.effects });
|
|
318
|
+
}
|
|
160
319
|
const published = [];
|
|
161
320
|
let predecessor;
|
|
162
321
|
try {
|
|
163
|
-
if (
|
|
164
|
-
const
|
|
165
|
-
const archivedHeader = mapping(archivedRecord.header) ? archivedRecord.header : {};
|
|
166
|
-
archivedHeader.status = "archived";
|
|
167
|
-
archivedRecord.header = archivedHeader;
|
|
168
|
-
const archived = replaceEntityUnderLock({ projectRoot: req.projectRoot, sourceRoot, publicationContext: options.publicationContext, artifact: ARTIFACT, boundary: PLAN, id: currentPredecessor.id, expectedRecord: currentPredecessor.record, expectedBytes: exactDiscoveredEntityBytes(currentPredecessor), migrationProvenance: currentPredecessor.migrationProvenance, record: archivedRecord });
|
|
322
|
+
if (lifecycle.predecessor && lifecycle.archivedRecord) {
|
|
323
|
+
const archived = replaceEntityUnderLock({ projectRoot: req.projectRoot, sourceRoot, publicationContext: options.publicationContext, artifact: ARTIFACT, boundary: PLAN, id: lifecycle.predecessor.id, expectedRecord: lifecycle.predecessor.record, expectedBytes: exactDiscoveredEntityBytes(lifecycle.predecessor), migrationProvenance: lifecycle.predecessor.migrationProvenance, record: lifecycle.archivedRecord });
|
|
169
324
|
if (!archived.publishedIdentity || archived.previousBytes === undefined)
|
|
170
|
-
throw new Error(`
|
|
325
|
+
throw new Error(`predecessor '${lifecycle.predecessor.id}' archive did not retain its exact recovery identity and bytes`);
|
|
171
326
|
predecessor = { relative: relative(req.projectRoot, archived.path), archivedIdentity: archived.publishedIdentity, bytes: archived.previousBytes };
|
|
172
327
|
}
|
|
173
328
|
for (const item of publications) {
|
|
@@ -208,7 +363,291 @@ function createPlanEntitiesUnderLock(req, options) {
|
|
|
208
363
|
throw new Error(`plan replacement failed: ${error.message}; recovery failed: ${recoveryFailures.join("; ")}`, { cause: error });
|
|
209
364
|
throw error;
|
|
210
365
|
}
|
|
211
|
-
return envelope(
|
|
366
|
+
return envelope(command, { id: planId, path: entityPath(req.projectRoot, sourceRoot, PLAN, planId), replay: false }, planRecord, false, { tasks: taskRecords.map((record, index) => ({ id: taskIds[index], artifact: ARTIFACT, record })), effects: lifecycle.effects });
|
|
367
|
+
}
|
|
368
|
+
function replacementEffects(predecessor, successor) {
|
|
369
|
+
return {
|
|
370
|
+
lifecycle: "targeted_replacement",
|
|
371
|
+
predecessor: {
|
|
372
|
+
id: predecessor.id,
|
|
373
|
+
transition: "archived",
|
|
374
|
+
preserved: ["task_records", "task_evaluations", "task_completion"],
|
|
375
|
+
},
|
|
376
|
+
successor: {
|
|
377
|
+
...(successor ? { id: successor.id } : {}),
|
|
378
|
+
created: !successor,
|
|
379
|
+
lineage: { field: "previous_plan_archived", predecessor: predecessor.id },
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function successorForPredecessor(entities, predecessor) {
|
|
384
|
+
const matches = entities.filter((entity) => entity.boundary === PLAN && entity.record?.previous_plan_archived === predecessor.id);
|
|
385
|
+
if (matches.length > 1) {
|
|
386
|
+
reject({
|
|
387
|
+
class: "conflict",
|
|
388
|
+
message: `archived predecessor '${predecessor.id}' has multiple canonical successor plans`,
|
|
389
|
+
recovery: "Repair canonical plan lineage so one archived predecessor has one successor before retrying targeted replacement.",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return matches[0];
|
|
393
|
+
}
|
|
394
|
+
function assertNoUnselectedOpenPlans(entities, selected, sourceRoot) {
|
|
395
|
+
const selectedIds = new Set(selected.map((entity) => entity.id));
|
|
396
|
+
const unselected = entities
|
|
397
|
+
.filter((entity) => entity.boundary === PLAN && planStatus(entity) === "open" && !selectedIds.has(entity.id))
|
|
398
|
+
.map((entity) => entity.id)
|
|
399
|
+
.sort();
|
|
400
|
+
if (unselected.length > 0) {
|
|
401
|
+
const conflict = multipleOpenPlanConflict(entities.filter((entity) => entity.boundary === PLAN && planStatus(entity) === "open"), sourceRoot);
|
|
402
|
+
reject({
|
|
403
|
+
class: "conflict",
|
|
404
|
+
message: `${conflict.message}; targeted plan replacement would leave ${unselected.length} unnamed open plan${unselected.length === 1 ? "" : "s"}`,
|
|
405
|
+
diagnosis: conflict.details,
|
|
406
|
+
recovery: conflict.recovery,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function existingReplacementDecision(entities, predecessor, successor, sourceRoot) {
|
|
411
|
+
if (predecessor.id === successor.id) {
|
|
412
|
+
reject({ class: "schema_violation", message: "targeted plan replacement requires distinct predecessor and successor IDs" });
|
|
413
|
+
}
|
|
414
|
+
const derived = successorForPredecessor(entities, predecessor);
|
|
415
|
+
if (planStatus(predecessor) === "archived") {
|
|
416
|
+
if (!derived || derived.id !== successor.id) {
|
|
417
|
+
reject({
|
|
418
|
+
class: "conflict",
|
|
419
|
+
message: `archived predecessor '${predecessor.id}' is already replaced by '${derived?.id ?? "no canonical successor"}', not '${successor.id}'`,
|
|
420
|
+
recovery: "Retry with the derived bare successor ID, or correct the divergent replacement request before any lifecycle mutation.",
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
predecessor,
|
|
425
|
+
successor,
|
|
426
|
+
successorRecord: structuredClone(successor.record),
|
|
427
|
+
replay: true,
|
|
428
|
+
effects: replacementEffects(predecessor, successor),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (!new Set(["open", "complete"]).has(planStatus(predecessor))) {
|
|
432
|
+
reject({ class: "conflict", message: `plan '${predecessor.id}' is ${planStatus(predecessor)} and cannot be a replacement predecessor` });
|
|
433
|
+
}
|
|
434
|
+
if (derived || successor.record?.previous_plan_archived !== undefined || successor.record?.replacement_input_sha256 !== undefined) {
|
|
435
|
+
reject({ class: "conflict", message: `successor '${successor.id}' already has canonical plan lineage and cannot be reassigned` });
|
|
436
|
+
}
|
|
437
|
+
if (planStatus(successor) !== "open") {
|
|
438
|
+
reject({ class: "conflict", message: `successor '${successor.id}' must be open for targeted replacement` });
|
|
439
|
+
}
|
|
440
|
+
assertNoUnselectedOpenPlans(entities, [predecessor, successor], sourceRoot);
|
|
441
|
+
const successorRecord = structuredClone(successor.record);
|
|
442
|
+
const inputSha256 = replacementSuccessorInputSha256(entities, successor);
|
|
443
|
+
successorRecord.previous_plan_archived = predecessor.id;
|
|
444
|
+
successorRecord.replacement_input_sha256 = inputSha256;
|
|
445
|
+
return {
|
|
446
|
+
predecessor,
|
|
447
|
+
successor,
|
|
448
|
+
archivedRecord: archivedPlanRecord(predecessor),
|
|
449
|
+
successorRecord,
|
|
450
|
+
inputSha256,
|
|
451
|
+
replay: false,
|
|
452
|
+
effects: replacementEffects(predecessor, successor),
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function replacementPlanRecord(input) {
|
|
456
|
+
const record = structuredClone(input);
|
|
457
|
+
delete record.tasks;
|
|
458
|
+
delete record.previous_plan_archived;
|
|
459
|
+
delete record.replacement_input_sha256;
|
|
460
|
+
const header = mapping(record.header) ? record.header : {};
|
|
461
|
+
delete header.id;
|
|
462
|
+
if (header.status === "active")
|
|
463
|
+
header.status = "open";
|
|
464
|
+
if (header.status === "completed")
|
|
465
|
+
header.status = "complete";
|
|
466
|
+
record.header = header;
|
|
467
|
+
return record;
|
|
468
|
+
}
|
|
469
|
+
function uniqueReplacementTaskNames(tasks, context, failureClass) {
|
|
470
|
+
const names = new Map();
|
|
471
|
+
for (const task of tasks) {
|
|
472
|
+
const name = task.name;
|
|
473
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
474
|
+
reject({ class: failureClass, message: `${context} contains a task without a non-empty name` });
|
|
475
|
+
}
|
|
476
|
+
if (names.has(name)) {
|
|
477
|
+
reject({ class: failureClass, message: `${context} contains duplicate task name '${name}', so replacement replay cannot identify a task` });
|
|
478
|
+
}
|
|
479
|
+
names.set(name, task);
|
|
480
|
+
}
|
|
481
|
+
return names;
|
|
482
|
+
}
|
|
483
|
+
function logicalReplacementTask(record, dependencies, resolveDependency) {
|
|
484
|
+
const logical = structuredClone(record);
|
|
485
|
+
for (const field of ["number", "plan", "status", "evaluation", "superseded_by", "superseded_reason"])
|
|
486
|
+
delete logical[field];
|
|
487
|
+
logical.depends_on = dependencies.map(resolveDependency);
|
|
488
|
+
if (!Array.isArray(logical.acceptance))
|
|
489
|
+
logical.acceptance = [];
|
|
490
|
+
return logical;
|
|
491
|
+
}
|
|
492
|
+
function replacementInputFingerprint(input) {
|
|
493
|
+
const tasks = Array.isArray(input.tasks) ? input.tasks.filter(mapping) : [];
|
|
494
|
+
const names = uniqueReplacementTaskNames(tasks, "replacement input", "schema_violation");
|
|
495
|
+
const byOrdinal = new Map(tasks.map((task, index) => [String(index + 1), String(task.name)]));
|
|
496
|
+
const logicalTasks = [...names.entries()].map(([name, task]) => {
|
|
497
|
+
const dependencies = Array.isArray(task.depends_on) ? task.depends_on : [];
|
|
498
|
+
const record = logicalReplacementTask(task, dependencies, (dependency) => {
|
|
499
|
+
const target = byOrdinal.get(String(dependency));
|
|
500
|
+
if (!target)
|
|
501
|
+
throw new Error(`replacement input dependency '${String(dependency)}' was not normalized before replay comparison`);
|
|
502
|
+
return target;
|
|
503
|
+
});
|
|
504
|
+
return [name, record];
|
|
505
|
+
});
|
|
506
|
+
return { plan: replacementPlanRecord(input), tasks: Object.fromEntries(logicalTasks.sort(([left], [right]) => left.localeCompare(right))) };
|
|
507
|
+
}
|
|
508
|
+
function replacementInputSha256(input) {
|
|
509
|
+
return createHash("sha256")
|
|
510
|
+
.update("agentera.planReplacementInput.v1\0")
|
|
511
|
+
.update(canonicalRecordJson(replacementInputFingerprint(input)))
|
|
512
|
+
.digest("hex");
|
|
513
|
+
}
|
|
514
|
+
function replacementSuccessorInputSha256(entities, successor) {
|
|
515
|
+
const tasks = entities.filter((entity) => entity.boundary === TASK && entity.record?.plan === successor.id);
|
|
516
|
+
const records = tasks.map((task) => structuredClone(task.record));
|
|
517
|
+
const names = uniqueReplacementTaskNames(records, `successor '${successor.id}'`, "conflict");
|
|
518
|
+
const namesById = new Map(tasks.map((task) => [task.id, String(task.record.name)]));
|
|
519
|
+
const logicalTasks = [...names.entries()].map(([name, task]) => {
|
|
520
|
+
const dependencies = Array.isArray(task.depends_on) ? task.depends_on : [];
|
|
521
|
+
const record = logicalReplacementTask(task, dependencies, (dependency) => {
|
|
522
|
+
const target = typeof dependency === "string" ? namesById.get(dependency) : undefined;
|
|
523
|
+
if (!target)
|
|
524
|
+
throw new Error(`successor '${successor.id}' has a task dependency outside its canonical task graph`);
|
|
525
|
+
return target;
|
|
526
|
+
});
|
|
527
|
+
return [name, record];
|
|
528
|
+
});
|
|
529
|
+
return createHash("sha256")
|
|
530
|
+
.update("agentera.planReplacementInput.v1\0")
|
|
531
|
+
.update(canonicalRecordJson({ plan: replacementPlanRecord(successor.record), tasks: Object.fromEntries(logicalTasks.sort(([left], [right]) => left.localeCompare(right))) }))
|
|
532
|
+
.digest("hex");
|
|
533
|
+
}
|
|
534
|
+
function assertReplacementInputReplay(input, successor) {
|
|
535
|
+
if (successor.record?.replacement_input_sha256 !== replacementInputSha256(input)) {
|
|
536
|
+
reject({
|
|
537
|
+
class: "conflict",
|
|
538
|
+
message: `replacement input diverges from immutable successor identity '${successor.id}'`,
|
|
539
|
+
recovery: "Retry the exact logical successor plan input, or start a new targeted replacement from an open predecessor; no state was changed.",
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function createReplacementLifecycle(entities, predecessor, inputSha256, sourceRoot) {
|
|
544
|
+
const successor = successorForPredecessor(entities, predecessor);
|
|
545
|
+
if (planStatus(predecessor) === "archived") {
|
|
546
|
+
if (!successor) {
|
|
547
|
+
reject({
|
|
548
|
+
class: "conflict",
|
|
549
|
+
message: `archived predecessor '${predecessor.id}' has no canonical successor to replay`,
|
|
550
|
+
recovery: "Do not create a second successor for an archived predecessor. Select an open predecessor or repair canonical lineage first.",
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return { replaySuccessor: successor };
|
|
554
|
+
}
|
|
555
|
+
if (!new Set(["open", "complete"]).has(planStatus(predecessor))) {
|
|
556
|
+
reject({ class: "conflict", message: `plan '${predecessor.id}' is ${planStatus(predecessor)} and cannot be a replacement predecessor` });
|
|
557
|
+
}
|
|
558
|
+
if (successor) {
|
|
559
|
+
reject({ class: "conflict", message: `predecessor '${predecessor.id}' already has successor '${successor.id}' before archival` });
|
|
560
|
+
}
|
|
561
|
+
assertNoUnselectedOpenPlans(entities, [predecessor], sourceRoot);
|
|
562
|
+
return {
|
|
563
|
+
lifecycle: {
|
|
564
|
+
predecessor,
|
|
565
|
+
archivedRecord: archivedPlanRecord(predecessor),
|
|
566
|
+
replacementInputSha256: inputSha256,
|
|
567
|
+
replay: false,
|
|
568
|
+
effects: replacementEffects(predecessor),
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function publishExistingPlanReplacement(req, options, sourceRoot, decision) {
|
|
573
|
+
const command = "state plan replace";
|
|
574
|
+
if (decision.replay)
|
|
575
|
+
return envelope(command, { id: decision.successor.id, path: decision.successor.path, replay: true }, decision.successorRecord, req.dryRun, { effects: decision.effects });
|
|
576
|
+
if (req.dryRun)
|
|
577
|
+
return envelope(command, { id: decision.successor.id, path: decision.successor.path, replay: false }, decision.successorRecord, true, { effects: decision.effects });
|
|
578
|
+
if (!decision.inputSha256)
|
|
579
|
+
throw new Error(`successor '${decision.successor.id}' has no immutable replacement input identity`);
|
|
580
|
+
publishPlanReplacement(options.publicationContext, sourceRoot, { kind: "existing", predecessor: decision.predecessor.id, successor: decision.successor.id, inputSha256: decision.inputSha256 }, [
|
|
581
|
+
{
|
|
582
|
+
path: relative(req.projectRoot, decision.predecessor.path),
|
|
583
|
+
before: exactDiscoveredEntityBytes(decision.predecessor),
|
|
584
|
+
after: canonicalEntityEnvelopeBytes({ id: decision.predecessor.id, artifact: ARTIFACT, record: decision.archivedRecord, migrationProvenance: decision.predecessor.migrationProvenance ?? undefined }),
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
path: relative(req.projectRoot, decision.successor.path),
|
|
588
|
+
before: exactDiscoveredEntityBytes(decision.successor),
|
|
589
|
+
after: canonicalEntityEnvelopeBytes({ id: decision.successor.id, artifact: ARTIFACT, record: decision.successorRecord, migrationProvenance: decision.successor.migrationProvenance ?? undefined }),
|
|
590
|
+
},
|
|
591
|
+
], {
|
|
592
|
+
validate: () => {
|
|
593
|
+
const validation = validateEntityState(options.publicationContext.pinnedPath(), sourceRoot, { kind: "project", projectRoot: options.publicationContext.validatedRoot });
|
|
594
|
+
if (!validation.valid)
|
|
595
|
+
throw new Error(`targeted plan replacement graph failed state validation: ${validation.issues.map(({ message }) => message).join("; ")}`);
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
return envelope(command, { id: decision.successor.id, path: decision.successor.path, replay: false }, decision.successorRecord, false, { effects: decision.effects });
|
|
599
|
+
}
|
|
600
|
+
export function replacePlanEntities(req, options = {}) {
|
|
601
|
+
if (!options.publicationContext) {
|
|
602
|
+
const binding = detectStateModeBinding(req.projectRoot, options.sourceRoot);
|
|
603
|
+
if (binding.mode !== "entities")
|
|
604
|
+
throw new Error("plan entity replacement requires the durable entity-mode marker");
|
|
605
|
+
try {
|
|
606
|
+
return replacePlanEntities(req, { ...options, publicationContext: binding.publicationContext });
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
binding.publicationContext.close();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return withEntityWriterLock(options.publicationContext, () => replacePlanEntitiesUnderLock(req, options));
|
|
613
|
+
}
|
|
614
|
+
function replacePlanEntitiesUnderLock(req, options) {
|
|
615
|
+
const command = "state plan replace";
|
|
616
|
+
const predecessorId = req.values.predecessor;
|
|
617
|
+
const successorId = req.values.successor;
|
|
618
|
+
if (typeof predecessorId !== "string")
|
|
619
|
+
reject({ class: "missing_argument", message: "--predecessor is required for plan replace" });
|
|
620
|
+
if (successorId !== undefined && typeof successorId !== "string")
|
|
621
|
+
reject({ class: "schema_violation", message: "--successor must be a bare plan ID when present" });
|
|
622
|
+
if (successorId !== undefined && req.input !== null)
|
|
623
|
+
reject({ class: "mutually_exclusive", message: "plan replace accepts either --successor or --input, not both" });
|
|
624
|
+
if (successorId === undefined && req.input === null)
|
|
625
|
+
reject({ class: "missing_argument", message: "plan replace requires either --successor ID or --input PLAN.yaml" });
|
|
626
|
+
const sourceRoot = options.sourceRoot ?? resolveSourceRoot();
|
|
627
|
+
const input = successorId === undefined ? preparedPlanCreateInput(req) : undefined;
|
|
628
|
+
const inputSha256 = input ? replacementInputSha256(input) : undefined;
|
|
629
|
+
recoverPendingPlanReplacement(options.publicationContext, sourceRoot, typeof successorId === "string"
|
|
630
|
+
? { predecessor: predecessorId, successor: successorId }
|
|
631
|
+
: { predecessor: predecessorId, inputSha256 }, {
|
|
632
|
+
validate: () => {
|
|
633
|
+
const validation = validateEntityState(options.publicationContext.pinnedPath(), sourceRoot, { kind: "project", projectRoot: options.publicationContext.validatedRoot });
|
|
634
|
+
if (!validation.valid)
|
|
635
|
+
throw new Error(`targeted plan replacement graph failed state validation: ${validation.issues.map(({ message }) => message).join("; ")}`);
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
const discovery = validateEntityState(options.publicationContext.pinnedPath(), sourceRoot, { kind: "project", projectRoot: options.publicationContext.validatedRoot });
|
|
639
|
+
const entities = all(options.publicationContext.pinnedPath(), sourceRoot, discovery);
|
|
640
|
+
const predecessor = selectedPlan(entities, predecessorId, sourceRoot);
|
|
641
|
+
if (typeof successorId === "string") {
|
|
642
|
+
const successor = selectedPlan(entities, successorId, sourceRoot);
|
|
643
|
+
return publishExistingPlanReplacement(req, options, sourceRoot, existingReplacementDecision(entities, predecessor, successor, sourceRoot));
|
|
644
|
+
}
|
|
645
|
+
const lifecycle = createReplacementLifecycle(entities, predecessor, inputSha256, sourceRoot);
|
|
646
|
+
if (lifecycle.replaySuccessor) {
|
|
647
|
+
assertReplacementInputReplay(input, lifecycle.replaySuccessor);
|
|
648
|
+
return envelope(command, { id: lifecycle.replaySuccessor.id, path: lifecycle.replaySuccessor.path, replay: true }, lifecycle.replaySuccessor.record, req.dryRun, { effects: replacementEffects(predecessor) });
|
|
649
|
+
}
|
|
650
|
+
return createPlanEntitiesUnderLock(req, { ...options, lifecycle: lifecycle.lifecycle, command });
|
|
212
651
|
}
|
|
213
652
|
function taskRecord(req, plan) {
|
|
214
653
|
const input = req.input ?? {};
|
|
@@ -342,13 +781,26 @@ function supersedeTask(entities, task, taskId, planId, values) {
|
|
|
342
781
|
return record;
|
|
343
782
|
}
|
|
344
783
|
export function mutatePlanEntities(req, options = {}) {
|
|
345
|
-
const sourceRoot = options.sourceRoot ?? resolveSourceRoot();
|
|
346
|
-
const entities = all(req.projectRoot, sourceRoot);
|
|
347
784
|
if (req.spec.verb === "create")
|
|
348
785
|
return createPlanEntities(req, options);
|
|
786
|
+
if (req.spec.verb === "replace")
|
|
787
|
+
return replacePlanEntities(req, options);
|
|
788
|
+
const sourceRoot = options.sourceRoot ?? resolveSourceRoot();
|
|
789
|
+
const entities = all(req.projectRoot, sourceRoot);
|
|
349
790
|
if (req.spec.verb === "set-plan-status" && req.values.id !== undefined)
|
|
350
791
|
reject({ class: "invalid_request", message: "plan set-plan-status accepts only the --plan selector; --id is a task selector and is not valid for plan lifecycle" });
|
|
351
|
-
|
|
792
|
+
if (req.spec.verb === "archive") {
|
|
793
|
+
const lifecycle = planLifecycleDecision(entities, { verb: "archive", force: req.force, ...(typeof req.values.plan === "string" ? { plan: req.values.plan } : {}) }, sourceRoot);
|
|
794
|
+
const plan = lifecycle.target;
|
|
795
|
+
const record = lifecycle.archivedRecord;
|
|
796
|
+
if (lifecycle.replay)
|
|
797
|
+
return envelope("state plan archive", { id: plan.id, path: plan.path, replay: true }, record, req.dryRun, { effects: lifecycle.effects });
|
|
798
|
+
if (req.dryRun)
|
|
799
|
+
return envelope("state plan archive", { id: plan.id, path: plan.path, replay: false }, record, true, { effects: lifecycle.effects });
|
|
800
|
+
const result = replaceEntity({ projectRoot: req.projectRoot, sourceRoot, publicationContext: options.publicationContext, artifact: ARTIFACT, boundary: PLAN, id: plan.id, expectedRecord: plan.record, expectedBytes: exactDiscoveredEntityBytes(plan), migrationProvenance: plan.migrationProvenance, record });
|
|
801
|
+
return envelope("state plan archive", result, record, false, { effects: lifecycle.effects });
|
|
802
|
+
}
|
|
803
|
+
const plan = selectedPlan(entities, typeof req.values.plan === "string" ? req.values.plan : undefined, sourceRoot);
|
|
352
804
|
if (req.spec.verb === "append") {
|
|
353
805
|
if (!OPEN.has(planStatus(plan)))
|
|
354
806
|
reject({ class: "conflict", message: `plan '${plan.id}' is ${planStatus(plan)} and cannot accept a new task` });
|
|
@@ -364,16 +816,16 @@ export function mutatePlanEntities(req, options = {}) {
|
|
|
364
816
|
const result = publishEntity({ projectRoot: req.projectRoot, sourceRoot, publicationContext: options.publicationContext, artifact: ARTIFACT, boundary: TASK, id, record });
|
|
365
817
|
return envelope("state plan append", result, record, false);
|
|
366
818
|
}
|
|
367
|
-
if (req.spec.verb === "set-plan-status"
|
|
819
|
+
if (req.spec.verb === "set-plan-status") {
|
|
368
820
|
const tasks = entities.filter((entity) => entity.boundary === TASK && entity.record?.plan === plan.id);
|
|
369
|
-
const requested =
|
|
370
|
-
if (planStatus(plan) === "archived"
|
|
821
|
+
const requested = String(req.values.status);
|
|
822
|
+
if (planStatus(plan) === "archived")
|
|
371
823
|
reject({ class: "conflict", message: `archived plan '${plan.id}' is immutable` });
|
|
372
824
|
const record = structuredClone(plan.record);
|
|
373
825
|
const header = mapping(record.header) ? record.header : {};
|
|
374
826
|
header.status = requested;
|
|
375
827
|
record.header = header;
|
|
376
|
-
const command =
|
|
828
|
+
const command = "state plan set-plan-status";
|
|
377
829
|
if (canonicalRecordJson(record) === canonicalRecordJson(plan.record))
|
|
378
830
|
return envelope(command, { id: plan.id, path: plan.path, replay: true }, record, req.dryRun);
|
|
379
831
|
if (requested === "complete" && tasks.some((task) => !["complete", "superseded"].includes(String(task.record?.status))))
|
|
@@ -513,15 +965,15 @@ function boundedList(root, sourceRoot, selected, allEntities, limit, cursor, ord
|
|
|
513
965
|
const response = { schemaVersion: "agentera.stateList.v1", command, status: remaining ? "degraded" : "ok", entries: page.map((entity) => entry(root, entity)), counts: { total: selected.length, returned: page.length, remaining }, order, filters: filter, snapshot: { id: snap, first_page: !cursor, has_more: Boolean(remaining), candidate_count: selected.length }, source: { artifact: ARTIFACT, authority: "canonical_entity_files", root: declared.entityRoot }, retrieval: { ...(next ? { continue: `${command.replace("state ", "agentera state ")}${familyIdentifier}${filterFlags}${selectorFlags} --limit ${take} --cursor ${next} --format json` } : {}) }, ...(remaining ? { omitted: true, omitted_count: remaining, omission_reason: "page_limit", next_cursor: next } : {}), ...envelope };
|
|
514
966
|
return projectEntityList(response, selector, projectionOptions);
|
|
515
967
|
}
|
|
516
|
-
export function getPlanEntity(root, id, sourceRoot = resolveSourceRoot()) { const entities = all(root, sourceRoot); const plan = selectedPlan(entities, id); return { schemaVersion: "agentera.stateGet.v1", command: "state plan get", status: "ok", entry: entry(root, plan), tasks: entities.filter((entity) => entity.boundary === TASK && entity.record?.plan === id).sort((a, b) => a.id.localeCompare(b.id)).map((entity) => entry(root, entity)), source_contract: { authority: "references/artifacts/state-storage-authority.yaml", detail: "full_entities" } }; }
|
|
968
|
+
export function getPlanEntity(root, id, sourceRoot = resolveSourceRoot()) { const entities = all(root, sourceRoot); const plan = selectedPlan(entities, id, sourceRoot); return { schemaVersion: "agentera.stateGet.v1", command: "state plan get", status: "ok", entry: entry(root, plan), tasks: entities.filter((entity) => entity.boundary === TASK && entity.record?.plan === id).sort((a, b) => a.id.localeCompare(b.id)).map((entity) => entry(root, entity)), source_contract: { authority: "references/artifacts/state-storage-authority.yaml", detail: "full_entities" } }; }
|
|
517
969
|
export function listPlanEntities(root, limit, cursor, options = {}) { const sourceRoot = options.sourceRoot ?? resolveSourceRoot(); const entities = all(root, sourceRoot, options.discovery); const statuses = options.statuses?.length ? new Set(options.statuses) : undefined; const plans = entities.filter((entity) => entity.boundary === PLAN && (!statuses || statuses.has(planStatus(entity)))).sort((a, b) => String(mapping(b.record?.header) ? b.record.header.created ?? "" : "").localeCompare(String(mapping(a.record?.header) ? a.record.header.created ?? "" : "")) || a.id.localeCompare(b.id)); return boundedList(root, sourceRoot, plans, entities, limit, cursor, ORDER, "state plan list", options.statuses ? { status: options.statuses } : {}, options.format, {}, undefined, options.selector); }
|
|
518
970
|
export function getPlanTaskEntity(root, id, planId, sourceRoot = resolveSourceRoot()) { const entities = all(root, sourceRoot); const task = taskFor(entities, id, planId); return { schemaVersion: "agentera.stateGet.v1", command: "state plan tasks get", status: "ok", entry: entry(root, task), source_contract: { authority: "references/artifacts/state-storage-authority.yaml", detail: "full_entity" } }; }
|
|
519
971
|
export function listPlanTaskEntities(root, planId, limit, cursor, options = {}) { const sourceRoot = options.sourceRoot ?? resolveSourceRoot(); const entities = all(root, sourceRoot, options.discovery); const declared = contract(sourceRoot); const decodedCursor = cursor ? decode(cursor, root, declared.authorityPath) : undefined; const cursorFilter = mapping(decodedCursor?.filter) ? decodedCursor.filter : undefined; const cursorPlan = typeof cursorFilter?.plan === "string" && ID.test(cursorFilter.plan) ? cursorFilter.plan : undefined; if (decodedCursor && !cursorPlan)
|
|
520
|
-
throw failure("cursor_snapshot_unavailable", "plan state changed after this cursor snapshot", "Omit --cursor to restart from current state."); const plan = selectedPlan(entities, planId ?? cursorPlan); const tasks = entities.filter((entity) => entity.boundary === TASK && entity.record?.plan === plan.id).sort((a, b) => a.id.localeCompare(b.id)); return boundedList(root, sourceRoot, tasks, entities, limit, cursor, TASK_ORDER, "state plan tasks list", { plan: plan.id }, options.format, {}, decodedCursor, options.selector); }
|
|
972
|
+
throw failure("cursor_snapshot_unavailable", "plan state changed after this cursor snapshot", "Omit --cursor to restart from current state."); const plan = selectedPlan(entities, planId ?? cursorPlan, sourceRoot); const tasks = entities.filter((entity) => entity.boundary === TASK && entity.record?.plan === plan.id).sort((a, b) => a.id.localeCompare(b.id)); return boundedList(root, sourceRoot, tasks, entities, limit, cursor, TASK_ORDER, "state plan tasks list", { plan: plan.id }, options.format, {}, decodedCursor, options.selector); }
|
|
521
973
|
export function currentPlanEntityView(root, limit, cursor, status, options = {}) {
|
|
522
974
|
const sourceRoot = options.sourceRoot ?? resolveSourceRoot();
|
|
523
975
|
const entities = all(root, sourceRoot);
|
|
524
|
-
const plan = selectedPlan(entities);
|
|
976
|
+
const plan = selectedPlan(entities, undefined, sourceRoot);
|
|
525
977
|
let tasks = entities.filter((entity) => entity.boundary === TASK && entity.record?.plan === plan.id);
|
|
526
978
|
if (status)
|
|
527
979
|
tasks = tasks.filter((entity) => entity.record?.status === status);
|