Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
e7b16ae
feat(cli): add timeline agent mutation commands
miguel-heygen Sep 20, 2026
320aac2
fix(cli): type timeline a2 mutations
miguel-heygen Sep 20, 2026
23f0d14
style(cli): format timeline a2 commands
miguel-heygen Sep 20, 2026
d8d7ff1
test(cli): cover timeline a2 verbs
miguel-heygen Sep 20, 2026
75a0555
test(cli): assert ids keep named refs
miguel-heygen Sep 20, 2026
4e9c886
refactor: centralize timeline duplicate mutation
miguel-heygen Sep 20, 2026
949a56a
fix(cli): narrow set assignment field
miguel-heygen Sep 20, 2026
2a64d9b
style(cli): format timeline mutation exports
miguel-heygen Sep 20, 2026
efd406a
refactor(cli): split apply plan decisions
miguel-heygen Sep 20, 2026
3a069f4
fix(cli): tag apply plan refusals
miguel-heygen Sep 20, 2026
d2a4486
refactor(cli): move A2 runners beside timeline helpers
miguel-heygen Sep 20, 2026
c205ba2
fix(cli): remove stale A2 imports
miguel-heygen Sep 20, 2026
aed308b
style(cli): format A2 command module
miguel-heygen Sep 20, 2026
fb66f2d
fix(cli): avoid A2 command import cycle
miguel-heygen Sep 20, 2026
821f973
refactor(cli): isolate shared timeline mutation logic
miguel-heygen Sep 20, 2026
dd26ab9
fix(cli): restore timeline mutation command factory
miguel-heygen Sep 20, 2026
e136e5d
fix(cli): remove stale command-surface remnants
miguel-heygen Sep 20, 2026
cd5b452
fix(cli): complete shared timeline support extraction
miguel-heygen Sep 21, 2026
f954335
fix(cli): close shared conflict helper
miguel-heygen Sep 21, 2026
a81f20e
fix(cli): import shared timeline formatter
miguel-heygen Sep 21, 2026
02a8c56
style(cli): format shared mutation runner
miguel-heygen Sep 21, 2026
59db2ec
refactor(cli): split mutation command orchestration
miguel-heygen Sep 21, 2026
56263ef
fix(cli): keep shared mutation primitives in support module
miguel-heygen Sep 21, 2026
7f52d61
fix(cli): use shared mutation support helpers
miguel-heygen Sep 21, 2026
39b9fad
refactor(cli): centralize A2 refusals
miguel-heygen Sep 21, 2026
3528bfe
style(cli): format A2 refusal helpers
miguel-heygen Sep 21, 2026
737ef03
fix(cli): clean shared A2 imports
miguel-heygen Sep 21, 2026
78614c7
fix(cli): make A2 runner imports explicit
miguel-heygen Sep 21, 2026
9449fb1
refactor(cli): isolate apply plan target validation
miguel-heygen Sep 21, 2026
a31058a
fix(cli): tag prepared plan edits
miguel-heygen Sep 21, 2026
2ac43fc
style(cli): format plan target helper
miguel-heygen Sep 21, 2026
c3bbf65
fix(cli): validate sequential plans and duplicate ids
miguel-heygen Sep 21, 2026
91343d0
test(cli): account for stamped composition id
miguel-heygen Sep 21, 2026
42d858f
fix(studio): stamp ids on patched html
miguel-heygen Sep 21, 2026
35f2a3b
fix(studio): preserve no-op patch bytes
miguel-heygen Sep 21, 2026
0fff963
test(studio): expect stamped patch markup
miguel-heygen Sep 21, 2026
19745e7
fix(studio-server): duplicate elements in nested templates
miguel-heygen Sep 21, 2026
46fcf17
refactor(cli): share timeline mutation receipts
miguel-heygen Sep 21, 2026
95480b1
fix(studio-server): harden duplicate and no-op mutations
miguel-heygen Sep 21, 2026
a715614
fix(studio-server): reduce duplicate helper complexity
miguel-heygen Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
619 changes: 40 additions & 579 deletions packages/cli/src/commands/timeline.ts

Large diffs are not rendered by default.

291 changes: 291 additions & 0 deletions packages/cli/src/timeline/a2Commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import { applyFileMutations, fileContentVersion } from "@hyperframes/studio-server";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describeProject, type ProjectTimeline, type TimelineRow } from "./describeProject.js";
import { resolveRef } from "./resolveRef.js";
import { parseTimeExpression } from "./timeExpr.js";
import { ensureDOMParser } from "../utils/dom.js";
import { resolveProject } from "../utils/project.js";
import { withMeta } from "../utils/updateCheck.js";
import {
allRows,
decideMutation,
diff,
fpsFor,
isRecord,
mutationConflict,
positional,
publicReceipt,
refuse,
rowAt,
type MutationContext,
type MutationVerb,
} from "./a2Shared.js";

export async function runIds(args: Record<string, unknown>): Promise<void> {
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
const json = args.json === true;
ensureDOMParser();
const beforeTimeline = await describeProject(project.indexPath);
const files = [...new Set(["index.html", ...allRows(beforeTimeline).map((row) => row.file)])];
const inputs = files.flatMap((file) => {
const before = readFileSync(join(project.dir, file), "utf-8");
const after = ensureHfIds(before);
return after === before
? []
: [{ sourceFile: file, absPath: join(project.dir, file), before, after }];
});
const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : [];
const afterTimeline = await describeProject(project.indexPath);
const result = {
ok: true,
receipt: receipts.map((receipt) => publicReceipt(receipt)),
file: files,
before: allRows(beforeTimeline),
after: allRows(afterTimeline),
diff: "",
warnings: [],
};
if (json) console.log(JSON.stringify(withMeta(result), null, 2));
else console.log(`ids: stamped ${receipts.length} file${receipts.length === 1 ? "" : "s"}`);
}
type ApplyEditResult =
| { ok: true; file: string; after: string }
| { ok: false; reason: string; fix: string };

const PLAN_VERBS = new Set<MutationVerb>(["move", "trim", "split", "delete", "set", "duplicate"]);

type PreparedPlanEdit = {
ok: true;
edit: Record<string, unknown>;
row: TimelineRow;
before: string;
resolved: Extract<ReturnType<typeof resolveRef>, { ok: true }>;
};

function preparePlanEdit(
edit: unknown,
timeline: ProjectTimeline,
sourceByFile: Map<string, string>,
): PreparedPlanEdit | { ok: false; reason: string; fix: string } {
if (!isRecord(edit) || typeof edit.verb !== "string" || typeof edit.ref !== "string") {
return {
ok: false,
reason: "each edit needs a verb and ref",
fix: "pass {verb, ref, ...} objects",
};
}
if (!PLAN_VERBS.has(edit.verb as MutationVerb)) {
return {
ok: false,
reason: `unsupported edit verb ${edit.verb}`,
fix: "use move, trim, split, delete, set, or duplicate",
};
}
const resolved = resolveRef(timeline, edit.ref);
if (!resolved.ok) return resolved;
const before = sourceByFile.get(resolved.row.file);
return before === undefined
? { ok: false, reason: `${resolved.row.file} was not found`, fix: "choose an existing clip" }
: { ok: true, edit, row: resolved.row, before, resolved };
}

function applyPlanEdit(
edit: unknown,
timeline: ProjectTimeline,
project: ReturnType<typeof resolveProject>,
sourceByFile: Map<string, string>,
): ApplyEditResult {
const prepared = preparePlanEdit(edit, timeline, sourceByFile);
if (!prepared.ok) return prepared;
const { edit: planEdit, row, before, resolved } = prepared;
const verb = planEdit.verb as MutationVerb;
const context: MutationContext = {
ref: planEdit.ref as string,
row,
before,
resolved,
parseTime: (expression) =>
parseTimeExpression(expression, {
row,
duration: timeline.duration,
fps: fpsFor(project.indexPath),
resolveAnchor: (anchorRef) => {
const anchor = resolveRef(timeline, anchorRef);
return anchor.ok ? anchor.row : undefined;
},
}),
duration: row.nested && row.hostRow ? rowAt(timeline, row.hostRow).duration : timeline.duration,
};
const decision = decideMutation(verb, context, { ...planEdit, _: [planEdit.ref] });
if (!decision.ok) return { ok: false, reason: decision.reason, fix: decision.fix };
const conflict = mutationConflict(
verb as MutationVerb,
planEdit.overwrite === true,
row,
timeline,
decision.nextStart,
decision.nextDuration,
);
if (conflict) return { ok: false, reason: conflict.reason, fix: conflict.fix };
return { ok: true, file: row.file, after: decision.after };
}

type EditPlan = { ok: true; edits: unknown[] } | { ok: false; reason: string; fix: string };

function readEditPlan(file: string): EditPlan {
const raw = file === "-" ? readFileSync(0, "utf-8") : readFileSync(file, "utf-8");
try {
const edits: unknown = JSON.parse(raw);
return Array.isArray(edits)
? { ok: true, edits }
: { ok: false, reason: "edit plan must be a JSON array", fix: "pass a JSON array of edits" };
} catch {
return { ok: false, reason: "edit plan is not valid JSON", fix: "pass a JSON array of edits" };
}
}

function readPlanSources(
timeline: ProjectTimeline,
project: ReturnType<typeof resolveProject>,
): { sourceByFile: Map<string, string>; beforeByFile: Map<string, string> } {
const sourceByFile = new Map<string, string>();
const beforeByFile = new Map<string, string>();
for (const fileName of new Set(allRows(timeline).map((row) => row.file))) {
const source = readFileSync(join(project.dir, fileName), "utf-8");
sourceByFile.set(fileName, source);
beforeByFile.set(fileName, source);
}
return { sourceByFile, beforeByFile };
}

async function applyPlanEdits(
edits: unknown[],
timeline: ProjectTimeline,
project: ReturnType<typeof resolveProject>,
sourceByFile: Map<string, string>,
): Promise<{ ok: true } | { ok: false; reason: string; fix: string }> {
for (const edit of edits) {
const result = applyPlanEdit(edit, timeline, project, sourceByFile);
if (!result.ok) return result;
sourceByFile.set(result.file, result.after);
timeline = await describeProject(project.indexPath, undefined, sourceByFile);
}
return { ok: true };
}

export async function runApply(args: Record<string, unknown>): Promise<void> {
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
const json = args.json === true;
const plan = args.plan === true;
const file = typeof args.file === "string" ? args.file : positional(args)[1];
if (!file)
return refuse(
"timeline apply",
{ reason: "an edit plan is required", fix: "pass an edits.json path or -" },
json,
);
const planInput = readEditPlan(file);
if (!planInput.ok) return refuse("timeline apply", planInput, json);
ensureDOMParser();
const timeline = await describeProject(project.indexPath);
const { sourceByFile, beforeByFile } = readPlanSources(timeline, project);
const editsResult = await applyPlanEdits(planInput.edits, timeline, project, sourceByFile);
if (!editsResult.ok) return refuse("timeline apply", editsResult, json);
const inputs = [...sourceByFile].flatMap(([fileName, after]) => {
const before = beforeByFile.get(fileName)!;
return after === before
? []
: [
{
sourceFile: fileName,
absPath: join(project.dir, fileName),
before,
after,
expectedVersion: fileContentVersion(before),
},
];
});
const afterTimeline = await describeProject(project.indexPath, undefined, sourceByFile);
const result = {
ok: true,
planned: plan,
receipt: null as unknown,
file: inputs.map((input) => input.sourceFile),
before: allRows(timeline),
after: allRows(afterTimeline),
diff: inputs
.map((input) => diff(input.before, input.after))
.filter(Boolean)
.join("\n"),
warnings: [],
};
if (!plan) {
const receipts = inputs.length > 0 ? applyFileMutations(project.dir, inputs) : [];
result.receipt = receipts.map((receipt) => publicReceipt(receipt));
}
if (json) console.log(JSON.stringify(withMeta(result), null, 2));
else
console.log(
`${plan ? "planned" : "applied"} ${inputs.length} file${inputs.length === 1 ? "" : "s"}`,
);
}

export async function runUndo(args: Record<string, unknown>): Promise<void> {
const project = resolveProject(typeof args.dir === "string" ? args.dir : undefined);
const json = args.json === true;
const input = typeof args.receipt === "string" ? args.receipt : positional(args)[1];
if (!input)
return refuse(
"timeline undo",
{ reason: "an undo receipt is required", fix: "pass the receipt JSON or its file" },
json,
);
let raw: string;
try {
raw = readFileSync(input, "utf-8");
} catch {
raw = input;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return refuse(
"timeline undo",
{ reason: "undo receipt is not valid JSON", fix: "pass the applied JSON receipt" },
json,
);
}
const value = isRecord(parsed) && isRecord(parsed.receipt) ? parsed.receipt : parsed;
if (
!isRecord(value) ||
typeof value.file !== "string" ||
typeof value.version !== "string" ||
typeof value.backupPath !== "string"
) {
return refuse(
"timeline undo",
{
reason: "undo receipt is missing file, version, or backupPath",
fix: "pass an applied timeline receipt",
},
json,
);
}
const backup = join(project.dir, value.backupPath);
const target = join(project.dir, value.file);
const before = readFileSync(target, "utf-8");
const after = readFileSync(backup, "utf-8");
const receipts = applyFileMutations(project.dir, [
{ sourceFile: value.file, absPath: target, before, after, expectedVersion: value.version },
]);
const result = {
ok: true,
receipt: receipts.map((receipt) => publicReceipt(receipt)),
file: value.file,
};
if (json) console.log(JSON.stringify(withMeta(result), null, 2));
else console.log(`undid ${value.file}`);
}
Loading
Loading