@mean-weasel/lineage 0.1.5 → 0.1.6

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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/lineageCli.ts
4
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3 } from "node:fs";
4
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3 } from "node:fs";
5
5
  import { homedir, platform } from "node:os";
6
- import { dirname as dirname3, join as join5, resolve as resolve3 } from "node:path";
6
+ import { dirname as dirname3, join as join6, resolve as resolve4 } from "node:path";
7
7
  import { spawn } from "node:child_process";
8
8
  import { fileURLToPath as fileURLToPath2 } from "node:url";
9
9
 
@@ -40,12 +40,92 @@ var mimeByExt = {
40
40
  ".webp": "image/webp"
41
41
  };
42
42
  var localReviewExts = new Set(Object.keys(mimeByExt));
43
+ var channelFromName = /\b(linkedin|meta|tiktok|youtube|x-twitter|x)\b/i;
44
+ var campaignFromPath = /\b(20\d{2}-\d{2}-[a-z0-9-]+)\b/i;
45
+ function localReviewRoot(repoRoot2) {
46
+ return join(repoRoot2, ".asset-scratch");
47
+ }
43
48
  function contentTypeFor(file) {
44
49
  return mimeByExt[extname(file).toLowerCase()] || "application/octet-stream";
45
50
  }
46
51
  function fileSha256(file) {
47
52
  return createHash("sha256").update(readFileSync(file)).digest("hex");
48
53
  }
54
+ function walkLocalReviewFiles(dir, files = []) {
55
+ if (!existsSync(dir)) return files;
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(dir, { withFileTypes: true });
59
+ } catch (error) {
60
+ if (error.code === "ENOENT") return files;
61
+ throw error;
62
+ }
63
+ for (const entry of entries) {
64
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "studio-uploads" || entry.name === "playwright-results" || entry.name === "lineage-demo") continue;
65
+ if (process.env.NODE_ENV !== "test" && /^vitest-/.test(entry.name)) continue;
66
+ const path = join(dir, entry.name);
67
+ if (entry.isDirectory()) walkLocalReviewFiles(path, files);
68
+ else if (entry.isFile() && localReviewExts.has(extname(entry.name).toLowerCase())) files.push(path);
69
+ }
70
+ return files;
71
+ }
72
+ function inferCampaign(relativePath) {
73
+ return campaignFromPath.exec(relativePath)?.[1] || "local-review";
74
+ }
75
+ function inferChannel(relativePath) {
76
+ const match = channelFromName.exec(relativePath);
77
+ if (!match) return "local";
78
+ return match[1].toLowerCase() === "x" ? "x-twitter" : match[1].toLowerCase();
79
+ }
80
+ function inferContentType(file) {
81
+ const mime = contentTypeFor(file);
82
+ if (mime.startsWith("image/gif")) return "gif";
83
+ if (mime.startsWith("image/")) return "image";
84
+ if (mime.startsWith("video/")) return "video";
85
+ return "other";
86
+ }
87
+ function listLocalReviewAssets(repoRoot2, project, catalog) {
88
+ const catalogChecksums = new Set(catalog.assets.map((asset) => asset.s3?.checksum_sha256).filter(Boolean));
89
+ const root = localReviewRoot(repoRoot2);
90
+ return walkLocalReviewFiles(root).flatMap((file) => {
91
+ try {
92
+ const stats = statSync(file);
93
+ const checksum = fileSha256(file);
94
+ const relativePath = relative(root, file);
95
+ return [{ checksum, file, relativePath, stats }];
96
+ } catch (error) {
97
+ if (error.code === "ENOENT") return [];
98
+ throw error;
99
+ }
100
+ }).filter((item) => !catalogChecksums.has(item.checksum)).map((item) => {
101
+ const fileSlug = basename(item.file, extname(item.file));
102
+ const channel = inferChannel(item.relativePath);
103
+ return {
104
+ asset_id: `local-${item.checksum.slice(0, 12)}`,
105
+ project,
106
+ product: project,
107
+ source: "local",
108
+ campaign: inferCampaign(item.relativePath),
109
+ channel,
110
+ audience: "local-review",
111
+ status: "planned",
112
+ content_type: inferContentType(item.file),
113
+ title: fileSlug.replace(/[-_]+/g, " "),
114
+ hook: item.relativePath,
115
+ cta: "Review before upload",
116
+ utm_content: fileSlug.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase() || "local_review",
117
+ notes: "Local pre-push asset. Review and refine before uploading to S3.",
118
+ local: {
119
+ relative_path: item.relativePath,
120
+ absolute_path: item.file,
121
+ size_bytes: item.stats.size,
122
+ content_type: contentTypeFor(item.file),
123
+ checksum_sha256: item.checksum,
124
+ updated_at: item.stats.mtime.toISOString()
125
+ }
126
+ };
127
+ });
128
+ }
49
129
 
50
130
  // src/server/adapters/storage/s3StorageAdapter.ts
51
131
  function parseAssetIdFromS3Key(key) {
@@ -227,6 +307,7 @@ var defaultProduct = process.env.LINEAGE_DEFAULT_PRODUCT || defaultProject;
227
307
  var publicFallbackBucket = "lineage-demo-assets";
228
308
  var publicFallbackRegion = "us-east-1";
229
309
  var contentTypes = /* @__PURE__ */ new Set(["image", "video", "gif", "audio", "doc", "other"]);
310
+ var baseChannels = ["linkedin", "meta", "tiktok", "x-twitter", "youtube"];
230
311
  var projectNamePattern = /^[a-z0-9][a-z0-9-]*$/;
231
312
  var LineageAssetError = class extends Error {
232
313
  constructor(message, status = 400) {
@@ -360,6 +441,9 @@ function defaultFallbackCatalog() {
360
441
  project: defaultProject
361
442
  }, defaultProject);
362
443
  }
444
+ function isDefaultFallbackCatalog(catalog) {
445
+ return catalog.project === defaultProject && !existsSync3(catalogPath(defaultProject));
446
+ }
363
447
  function loadCatalog(project = defaultProject) {
364
448
  const path = catalogPath(project);
365
449
  if (existsSync3(path)) {
@@ -425,6 +509,67 @@ var storageAdapter = createS3StorageAdapter({
425
509
  saveCatalog,
426
510
  supportedContentTypes: contentTypes
427
511
  });
512
+ function uniqueSorted(values) {
513
+ return Array.from(new Set(values.filter(Boolean))).sort();
514
+ }
515
+ function facetsFor(assets) {
516
+ return {
517
+ audiences: uniqueSorted(assets.map((asset) => asset.audience)),
518
+ campaigns: uniqueSorted(assets.map((asset) => asset.campaign)),
519
+ channels: uniqueSorted([...baseChannels, ...assets.map((asset) => asset.channel)]),
520
+ contentTypes: uniqueSorted(assets.map((asset) => asset.content_type)),
521
+ placementStatuses: uniqueSorted(assets.flatMap((asset) => asset.placements?.map((placement) => placement.status) || [])),
522
+ statuses: uniqueSorted(assets.map((asset) => asset.status)),
523
+ totalSizeBytes: assets.reduce((sum, asset) => sum + (asset.s3?.size_bytes || 0), 0)
524
+ };
525
+ }
526
+ function filteredAssets(assets, options) {
527
+ const query = options.query?.trim().toLowerCase();
528
+ return assets.filter((asset) => {
529
+ if (options.status && options.status !== "all" && asset.status !== options.status) return false;
530
+ if (options.channel && options.channel !== "all" && asset.channel !== options.channel) return false;
531
+ if (options.type && options.type !== "all" && asset.content_type !== options.type) return false;
532
+ if (options.placementStatus === "not-posted" && asset.placements?.some((placement) => placement.status === "posted")) return false;
533
+ if (options.placementStatus && !["all", "not-posted"].includes(options.placementStatus) && !asset.placements?.some((placement) => placement.status === options.placementStatus)) return false;
534
+ if (options.campaign && options.campaign !== "all" && asset.campaign !== options.campaign) return false;
535
+ if (options.audience && options.audience !== "all" && asset.audience !== options.audience) return false;
536
+ if (!query) return true;
537
+ return [asset.asset_id, asset.title, asset.campaign, asset.channel, asset.audience, asset.hook, asset.cta].join(" ").toLowerCase().includes(query);
538
+ });
539
+ }
540
+ function listAssets(project = defaultProject, options = {}) {
541
+ const catalog = loadCatalog(project);
542
+ const pageSize = Math.min(Math.max(Number(options.pageSize || 10), 1), 100);
543
+ const page = Math.max(Number(options.page || 1), 1);
544
+ const localAssets = listLocalReviewAssets(repoRoot, catalog.project, catalog);
545
+ const source = options.source || "catalog";
546
+ const sourceAssets = source === "local" ? localAssets : source === "all" ? [...localAssets, ...catalog.assets] : catalog.assets;
547
+ const filtered = filteredAssets(sourceAssets, options);
548
+ const totalPages = Math.max(Math.ceil(filtered.length / pageSize), 1);
549
+ const safePage = Math.min(page, totalPages);
550
+ const start2 = (safePage - 1) * pageSize;
551
+ const pageAssets = filtered.slice(start2, start2 + pageSize);
552
+ let liveObjects = [];
553
+ let error;
554
+ if (options.includeLive && !isDefaultFallbackCatalog(catalog)) {
555
+ try {
556
+ liveObjects = storageAdapter.listLiveObjects(catalog);
557
+ } catch (err) {
558
+ error = err instanceof Error ? err.message : String(err);
559
+ }
560
+ }
561
+ return {
562
+ catalog: { project: catalog.project, product: catalog.product, default_bucket: catalog.default_bucket, default_region: catalog.default_region, asset_count: catalog.assets.length },
563
+ assets: pageAssets,
564
+ facets: facetsFor(catalog.assets),
565
+ pagination: { page: safePage, pageSize, total: filtered.length, totalPages },
566
+ liveObjects,
567
+ orphanObjects: liveObjects.filter((object) => !object.cataloged),
568
+ identity: options.includeLive && !isDefaultFallbackCatalog(catalog) ? storageAdapter.getIdentity() : void 0,
569
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
570
+ error
571
+ };
572
+ }
428
573
 
429
574
  // src/server/assetLineageDb.ts
430
575
  var require2 = createRequire(import.meta.url);
@@ -481,6 +626,44 @@ function lineageDb() {
481
626
  );
482
627
  create index if not exists edges_parent on asset_edges(project_id, parent_asset_id);
483
628
  create index if not exists edges_child on asset_edges(project_id, child_asset_id);
629
+ create table if not exists asset_attempts (
630
+ id text primary key,
631
+ project_id text not null references projects(id),
632
+ node_asset_id text not null references assets(id),
633
+ asset_id text not null references assets(id),
634
+ attempt_index integer not null check (attempt_index > 0),
635
+ source text not null check (source in ('initial', 'generated_child', 'reroll')),
636
+ prompt text,
637
+ generation_job_id text,
638
+ file_path text,
639
+ checksum_sha256 text,
640
+ created_at text not null,
641
+ promoted_at text,
642
+ is_current integer not null check (is_current in (0, 1)),
643
+ unique(project_id, node_asset_id, attempt_index),
644
+ unique(project_id, node_asset_id, asset_id, source)
645
+ );
646
+ create unique index if not exists asset_attempts_one_current
647
+ on asset_attempts(project_id, node_asset_id)
648
+ where is_current = 1;
649
+ create index if not exists asset_attempts_node_created
650
+ on asset_attempts(project_id, node_asset_id, created_at);
651
+ create table if not exists asset_reroll_requests (
652
+ id text primary key,
653
+ project_id text not null references projects(id),
654
+ root_asset_id text not null references assets(id),
655
+ node_asset_id text not null references assets(id),
656
+ status text not null check (status in ('pending', 'resolved', 'cancelled')),
657
+ requested_by text not null check (requested_by in ('human', 'agent', 'system')),
658
+ notes text,
659
+ created_at text not null,
660
+ resolved_at text
661
+ );
662
+ create unique index if not exists asset_reroll_requests_one_pending
663
+ on asset_reroll_requests(project_id, root_asset_id, node_asset_id)
664
+ where status = 'pending';
665
+ create index if not exists asset_reroll_requests_root_status
666
+ on asset_reroll_requests(project_id, root_asset_id, status, created_at);
484
667
  create table if not exists asset_reviews (
485
668
  asset_id text primary key references assets(id),
486
669
  review_state text not null check (review_state in ('unreviewed', 'approved', 'needs_revision', 'rejected', 'ignored')),
@@ -685,7 +868,7 @@ function lineageDb() {
685
868
  project_id text not null references projects(id),
686
869
  provider text not null default 'codex-handoff',
687
870
  adapter_version text not null,
688
- source_mode text not null check (source_mode in ('lineage_selection')),
871
+ source_mode text not null check (source_mode in ('lineage_selection', 'lineage_reroll')),
689
872
  root_asset_id text not null references assets(id),
690
873
  prompt text not null,
691
874
  expected_output_count integer not null check (expected_output_count > 0),
@@ -703,7 +886,7 @@ function lineageDb() {
703
886
  project_id text not null references projects(id),
704
887
  asset_id text not null references assets(id),
705
888
  root_asset_id text not null references assets(id),
706
- role text not null check (role in ('lineage_next_base', 'reference')),
889
+ role text not null check (role in ('lineage_next_base', 'reference', 'reroll_target')),
707
890
  position integer not null,
708
891
  selection_strategy text not null,
709
892
  selection_snapshot_json text not null,
@@ -1204,6 +1387,9 @@ function validateAgentClaimForWrite(fields) {
1204
1387
  }
1205
1388
  }
1206
1389
 
1390
+ // src/server/assetLineage.ts
1391
+ import { join as join5 } from "node:path";
1392
+
1207
1393
  // src/server/assetLineageSelection.ts
1208
1394
  function selectedRows(database, project, root) {
1209
1395
  return database.prepare(`
@@ -1336,6 +1522,70 @@ var LineageError = class extends Error {
1336
1522
  }
1337
1523
  status;
1338
1524
  };
1525
+ function collectAssets(project, source) {
1526
+ const first = listAssets(project, { source, page: 1, pageSize: 100 });
1527
+ const assets = [...first.assets];
1528
+ for (let page = 2; page <= first.pagination.totalPages; page += 1) {
1529
+ assets.push(...listAssets(project, { source, page, pageSize: 100 }).assets);
1530
+ }
1531
+ return assets;
1532
+ }
1533
+ function upsertProject(database, project) {
1534
+ const timestamp = nowIso();
1535
+ database.prepare(`
1536
+ insert into projects (id, product, catalog_path, created_at, updated_at)
1537
+ values (?, ?, ?, ?, ?)
1538
+ on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
1539
+ `).run(project, project, join5(repoRoot, project, "assets", "catalog.json"), timestamp, timestamp);
1540
+ }
1541
+ function upsertAsset(database, project, asset) {
1542
+ const timestamp = nowIso();
1543
+ const source = asset.source === "local" ? "local" : "catalog";
1544
+ database.prepare(`
1545
+ insert into assets (
1546
+ id, project_id, source, local_path, s3_key, checksum_sha256, media_type, title, status,
1547
+ channel, campaign, audience, size_bytes, content_type, created_at, updated_at, last_seen_at
1548
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1549
+ on conflict(id) do update set
1550
+ source = excluded.source, local_path = excluded.local_path, s3_key = excluded.s3_key,
1551
+ checksum_sha256 = excluded.checksum_sha256, media_type = excluded.media_type,
1552
+ title = excluded.title, status = excluded.status, channel = excluded.channel,
1553
+ campaign = excluded.campaign, audience = excluded.audience, size_bytes = excluded.size_bytes,
1554
+ content_type = excluded.content_type, updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
1555
+ `).run(
1556
+ asset.asset_id,
1557
+ project,
1558
+ source,
1559
+ asset.local?.relative_path || null,
1560
+ asset.s3?.key || null,
1561
+ asset.local?.checksum_sha256 || asset.s3?.checksum_sha256 || null,
1562
+ asset.content_type,
1563
+ asset.title,
1564
+ asset.status,
1565
+ asset.channel || null,
1566
+ asset.campaign || null,
1567
+ asset.audience || null,
1568
+ asset.local?.size_bytes || asset.s3?.size_bytes || null,
1569
+ asset.local?.content_type || asset.s3?.content_type || null,
1570
+ timestamp,
1571
+ timestamp,
1572
+ timestamp
1573
+ );
1574
+ database.prepare(`
1575
+ insert into asset_reviews (asset_id, review_state, updated_at)
1576
+ values (?, 'unreviewed', ?)
1577
+ on conflict(asset_id) do nothing
1578
+ `).run(asset.asset_id, timestamp);
1579
+ }
1580
+ function indexLineageAssets(project = defaultProject) {
1581
+ const database = lineageDb();
1582
+ const catalog = collectAssets(project, "catalog");
1583
+ const local = collectAssets(project, "local");
1584
+ upsertProject(database, project);
1585
+ for (const asset of [...catalog, ...local]) upsertAsset(database, project, asset);
1586
+ database.close();
1587
+ return { catalog: catalog.length, local: local.length, total: catalog.length + local.length, database: lineageDbPath() };
1588
+ }
1339
1589
  function requireAsset(database, project, assetId) {
1340
1590
  const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
1341
1591
  if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
@@ -1355,6 +1605,11 @@ function rootFor(database, project, assetId) {
1355
1605
  }
1356
1606
  return assetId;
1357
1607
  }
1608
+ function assertCanonicalRoot(database, project, root) {
1609
+ requireAsset(database, project, root);
1610
+ const canonicalRoot = rootFor(database, project, root);
1611
+ if (canonicalRoot !== root) throw new LineageError(`Asset ${root} is not a lineage root`, 400);
1612
+ }
1358
1613
  function explicitWorkspaceRoot(database, project, assetId) {
1359
1614
  const row = database.prepare(`
1360
1615
  select root_asset_id from lineage_workspaces
@@ -1411,6 +1666,75 @@ function resolveRoot(database, project, rootAssetId) {
1411
1666
  function edgeId(project, parent, child) {
1412
1667
  return `${project}:${parent}:derived_from:${child}`;
1413
1668
  }
1669
+ function rerollRequestId(project, root, node, timestamp) {
1670
+ return `${project}:${root}:reroll:${node}:${Date.parse(timestamp) || Date.now()}`;
1671
+ }
1672
+ function rowString(value) {
1673
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1674
+ }
1675
+ function rerollRequestFrom(row) {
1676
+ return {
1677
+ id: String(row.id),
1678
+ project_id: String(row.project_id),
1679
+ root_asset_id: String(row.root_asset_id),
1680
+ node_asset_id: String(row.node_asset_id),
1681
+ status: row.status,
1682
+ requested_by: row.requested_by,
1683
+ notes: rowString(row.notes),
1684
+ created_at: String(row.created_at),
1685
+ resolved_at: rowString(row.resolved_at)
1686
+ };
1687
+ }
1688
+ function attemptFrom(row) {
1689
+ return {
1690
+ id: String(row.id),
1691
+ project_id: String(row.project_id),
1692
+ node_asset_id: String(row.node_asset_id),
1693
+ asset_id: String(row.asset_id),
1694
+ attempt_index: Number(row.attempt_index),
1695
+ source: row.source,
1696
+ prompt: rowString(row.prompt),
1697
+ generation_job_id: rowString(row.generation_job_id),
1698
+ file_path: rowString(row.file_path),
1699
+ checksum_sha256: rowString(row.checksum_sha256),
1700
+ created_at: String(row.created_at),
1701
+ promoted_at: rowString(row.promoted_at),
1702
+ is_current: Boolean(Number(row.is_current))
1703
+ };
1704
+ }
1705
+ function implicitAttempt(row, isCurrent = true) {
1706
+ const createdAt = row.asset_created_at || nowIso();
1707
+ return {
1708
+ id: `${row.project}:${row.asset_id}:attempt:implicit`,
1709
+ project_id: row.project,
1710
+ node_asset_id: row.asset_id,
1711
+ asset_id: row.asset_id,
1712
+ attempt_index: 1,
1713
+ source: "initial",
1714
+ file_path: row.local_path,
1715
+ checksum_sha256: row.checksum_sha256,
1716
+ created_at: createdAt,
1717
+ promoted_at: createdAt,
1718
+ is_current: isCurrent
1719
+ };
1720
+ }
1721
+ function withImplicitAttempt(physicalAttempts, row) {
1722
+ if (physicalAttempts.some((attempt) => attempt.source === "initial")) return physicalAttempts;
1723
+ return [...physicalAttempts, implicitAttempt(row, !physicalAttempts.some((attempt) => attempt.is_current))];
1724
+ }
1725
+ function assertNodeInRoot(database, project, root, node) {
1726
+ assertCanonicalRoot(database, project, root);
1727
+ requireAsset(database, project, node);
1728
+ const nodeRoot = rootFor(database, project, node);
1729
+ if (nodeRoot !== root) throw new LineageError(`Asset ${node} is not in lineage rooted at ${root}`);
1730
+ }
1731
+ function assertAttemptAssetNotVisibleLineageNode(database, project, root, assetId) {
1732
+ const visibleNodeIds = /* @__PURE__ */ new Set([
1733
+ root,
1734
+ ...descendants(database, project, root).flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id])
1735
+ ]);
1736
+ if (visibleNodeIds.has(assetId)) throw new LineageError(`Attempt asset ${assetId} is already a visible lineage node in ${root}`, 400);
1737
+ }
1414
1738
  function canPreviewLocally(mediaType, localPath) {
1415
1739
  return Boolean(localPath && ["image", "video", "gif"].includes(mediaType));
1416
1740
  }
@@ -1481,11 +1805,21 @@ function getLineageSnapshot(project, assetId) {
1481
1805
  const rows = database.prepare(`
1482
1806
  select a.id asset_id, a.project_id project, a.source, a.title, a.media_type, a.status, a.channel, a.campaign,
1483
1807
  a.local_path, a.s3_key, a.checksum_sha256, coalesce(r.review_state, 'unreviewed') review_state,
1484
- r.notes review_notes, l.x layout_x, l.y layout_y
1808
+ r.notes review_notes, a.created_at asset_created_at, l.x layout_x, l.y layout_y
1485
1809
  from assets a left join asset_reviews r on r.asset_id = a.id
1486
1810
  left join asset_layouts l on l.project_id = a.project_id and l.root_asset_id = ? and l.asset_id = a.id
1487
1811
  where a.project_id = ? and a.id in (${placeholders})
1488
1812
  `).all(root, project, ...ids);
1813
+ const attemptRows = ids.length > 0 ? database.prepare(`select * from asset_attempts where project_id = ? and node_asset_id in (${placeholders}) order by node_asset_id, attempt_index desc`).all(project, ...ids) : [];
1814
+ const attemptsByNode = /* @__PURE__ */ new Map();
1815
+ for (const attempt of attemptRows.map(attemptFrom)) {
1816
+ attemptsByNode.set(attempt.node_asset_id, [...attemptsByNode.get(attempt.node_asset_id) || [], attempt]);
1817
+ }
1818
+ const rerollRows = ids.length > 0 ? database.prepare(`select * from asset_reroll_requests where project_id = ? and root_asset_id = ? and status = 'pending' and node_asset_id in (${placeholders}) order by created_at`).all(project, root, ...ids) : [];
1819
+ const rerollsByNode = new Map(rerollRows.map((row) => {
1820
+ const request = rerollRequestFrom(row);
1821
+ return [request.node_asset_id, request];
1822
+ }));
1489
1823
  const selected = selectedRows(database, project, root);
1490
1824
  const childIds = new Set(edges.map((edge) => edge.parent_asset_id));
1491
1825
  const selectedIds = new Set(selected.map((row) => row.asset_id));
@@ -1498,13 +1832,19 @@ function getLineageSnapshot(project, assetId) {
1498
1832
  const selection = selections[0] || null;
1499
1833
  const nodes = rows.map((row) => {
1500
1834
  const position = typeof row.layout_x === "number" && typeof row.layout_y === "number" ? { x: row.layout_x, y: row.layout_y } : void 0;
1501
- const { layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
1835
+ const { asset_created_at: _assetCreatedAt, layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
1502
1836
  const nodeSelection = selections.find((item) => item.asset_id === row.asset_id);
1837
+ const attempts = withImplicitAttempt(attemptsByNode.get(row.asset_id) || [], row);
1838
+ const currentAttempt = attempts.find((attempt) => attempt.is_current) || attempts[0];
1839
+ const previewPath = currentAttempt?.file_path || row.local_path;
1503
1840
  return {
1504
1841
  ...node,
1842
+ attempt_count: attempts.length,
1843
+ current_attempt: currentAttempt,
1505
1844
  is_latest: !childIds.has(row.asset_id),
1506
1845
  position,
1507
- preview_url: canPreviewLocally(row.media_type, row.local_path) ? localPreviewUrl(project, row.local_path) : void 0,
1846
+ preview_url: canPreviewLocally(row.media_type, previewPath) ? localPreviewUrl(project, previewPath) : void 0,
1847
+ reroll_request: rerollsByNode.get(row.asset_id),
1508
1848
  selection_note: nodeSelection?.notes,
1509
1849
  user_selected: selectedIds.has(row.asset_id)
1510
1850
  };
@@ -1591,6 +1931,160 @@ function getLineageNextAsset(project, rootAssetId) {
1591
1931
  fetchedAt: nowIso()
1592
1932
  };
1593
1933
  }
1934
+ function listLineageRerollRequests(project, rootAssetId) {
1935
+ const database = lineageDb();
1936
+ try {
1937
+ assertCanonicalRoot(database, project, rootAssetId);
1938
+ const rows = database.prepare(`
1939
+ select * from asset_reroll_requests
1940
+ where project_id = ? and root_asset_id = ? and status = 'pending'
1941
+ order by created_at, id
1942
+ `).all(project, rootAssetId);
1943
+ return { project, root_asset_id: rootAssetId, requests: rows.map(rerollRequestFrom), fetchedAt: nowIso() };
1944
+ } finally {
1945
+ database.close();
1946
+ }
1947
+ }
1948
+ function recordLineageRerollAttempt(project, fields) {
1949
+ const database = lineageDb();
1950
+ try {
1951
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
1952
+ requireAsset(database, project, fields.assetId);
1953
+ assertAttemptAssetNotVisibleLineageNode(database, project, fields.rootAssetId, fields.assetId);
1954
+ const timestamp = nowIso();
1955
+ const maxRow = database.prepare("select max(attempt_index) max_index from asset_attempts where project_id = ? and node_asset_id = ?").get(project, fields.nodeAssetId);
1956
+ const attemptIndex = Number(maxRow.max_index || 1) + 1;
1957
+ const attempt = {
1958
+ id: `${project}:${fields.nodeAssetId}:attempt:${attemptIndex}`,
1959
+ project_id: project,
1960
+ node_asset_id: fields.nodeAssetId,
1961
+ asset_id: fields.assetId,
1962
+ attempt_index: attemptIndex,
1963
+ source: "reroll",
1964
+ prompt: fields.prompt,
1965
+ generation_job_id: fields.generationJobId,
1966
+ file_path: fields.filePath,
1967
+ checksum_sha256: fields.checksumSha256,
1968
+ created_at: timestamp,
1969
+ promoted_at: timestamp,
1970
+ is_current: true
1971
+ };
1972
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, attempt };
1973
+ database.exec("BEGIN IMMEDIATE");
1974
+ try {
1975
+ database.prepare("update asset_attempts set is_current = 0 where project_id = ? and node_asset_id = ?").run(project, fields.nodeAssetId);
1976
+ database.prepare(`
1977
+ insert into asset_attempts (
1978
+ id, project_id, node_asset_id, asset_id, attempt_index, source, prompt, generation_job_id,
1979
+ file_path, checksum_sha256, created_at, promoted_at, is_current
1980
+ ) values (?, ?, ?, ?, ?, 'reroll', ?, ?, ?, ?, ?, ?, 1)
1981
+ `).run(
1982
+ attempt.id,
1983
+ project,
1984
+ fields.nodeAssetId,
1985
+ fields.assetId,
1986
+ attempt.attempt_index,
1987
+ fields.prompt,
1988
+ fields.generationJobId,
1989
+ fields.filePath,
1990
+ fields.checksumSha256,
1991
+ timestamp,
1992
+ timestamp
1993
+ );
1994
+ database.prepare(`
1995
+ update asset_reroll_requests
1996
+ set status = 'resolved', resolved_at = ?
1997
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
1998
+ `).run(timestamp, project, fields.rootAssetId, fields.nodeAssetId);
1999
+ database.prepare(`
2000
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
2001
+ values (?, 'unreviewed', ?, null, null, ?)
2002
+ on conflict(asset_id) do update set
2003
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
2004
+ ignored_at = excluded.ignored_at, updated_at = excluded.updated_at
2005
+ `).run(fields.nodeAssetId, timestamp, timestamp);
2006
+ database.exec("COMMIT");
2007
+ } catch (error) {
2008
+ database.exec("ROLLBACK");
2009
+ throw error;
2010
+ }
2011
+ return { ok: true, attempt };
2012
+ } finally {
2013
+ database.close();
2014
+ }
2015
+ }
2016
+ function markLineageRerollRequest(project, fields) {
2017
+ const database = lineageDb();
2018
+ try {
2019
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
2020
+ const existing = database.prepare(`
2021
+ select * from asset_reroll_requests
2022
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
2023
+ order by created_at desc limit 1
2024
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
2025
+ const timestamp = nowIso();
2026
+ const request = existing ? {
2027
+ ...rerollRequestFrom(existing),
2028
+ notes: fields.notes || rerollRequestFrom(existing).notes,
2029
+ requested_by: fields.requestedBy || rerollRequestFrom(existing).requested_by
2030
+ } : {
2031
+ id: rerollRequestId(project, fields.rootAssetId, fields.nodeAssetId, timestamp),
2032
+ project_id: project,
2033
+ root_asset_id: fields.rootAssetId,
2034
+ node_asset_id: fields.nodeAssetId,
2035
+ status: "pending",
2036
+ requested_by: fields.requestedBy || "human",
2037
+ notes: fields.notes,
2038
+ created_at: timestamp
2039
+ };
2040
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
2041
+ if (existing) {
2042
+ database.prepare(`
2043
+ update asset_reroll_requests
2044
+ set requested_by = ?, notes = ?
2045
+ where id = ?
2046
+ `).run(request.requested_by, request.notes || null, request.id);
2047
+ } else {
2048
+ database.prepare(`
2049
+ insert into asset_reroll_requests (id, project_id, root_asset_id, node_asset_id, status, requested_by, notes, created_at, resolved_at)
2050
+ values (?, ?, ?, ?, 'pending', ?, ?, ?, null)
2051
+ `).run(request.id, project, fields.rootAssetId, fields.nodeAssetId, request.requested_by, request.notes || null, request.created_at);
2052
+ }
2053
+ database.prepare(`
2054
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
2055
+ values (?, 'needs_revision', ?, null, ?, ?)
2056
+ on conflict(asset_id) do update set
2057
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
2058
+ ignored_at = excluded.ignored_at, notes = coalesce(excluded.notes, asset_reviews.notes), updated_at = excluded.updated_at
2059
+ `).run(fields.nodeAssetId, timestamp, request.notes || null, timestamp);
2060
+ return { ok: true, request: rerollRequestFrom(database.prepare("select * from asset_reroll_requests where id = ?").get(request.id)) };
2061
+ } finally {
2062
+ database.close();
2063
+ }
2064
+ }
2065
+ function clearLineageRerollRequest(project, fields) {
2066
+ const database = lineageDb();
2067
+ try {
2068
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
2069
+ const existing = database.prepare(`
2070
+ select * from asset_reroll_requests
2071
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
2072
+ order by created_at desc limit 1
2073
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
2074
+ if (!existing) throw new LineageError(`No pending re-roll request for ${fields.nodeAssetId}`, 404);
2075
+ const timestamp = nowIso();
2076
+ const request = { ...rerollRequestFrom(existing), status: "cancelled", resolved_at: timestamp };
2077
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
2078
+ database.prepare(`
2079
+ update asset_reroll_requests
2080
+ set status = 'cancelled', resolved_at = ?
2081
+ where id = ?
2082
+ `).run(timestamp, request.id);
2083
+ return { ok: true, request };
2084
+ } finally {
2085
+ database.close();
2086
+ }
2087
+ }
1594
2088
 
1595
2089
  // src/server/assetLineageHandoff.ts
1596
2090
  var publicPackageCommand = "npx @mean-weasel/lineage";
@@ -1679,17 +2173,307 @@ function linkSelectedLineageChild(project, fields) {
1679
2173
  };
1680
2174
  }
1681
2175
 
2176
+ // src/server/generationReceipts.ts
2177
+ import { existsSync as existsSync4, realpathSync, statSync as statSync3 } from "node:fs";
2178
+ import { relative as relative2, resolve as resolve3 } from "node:path";
2179
+ import { randomUUID } from "node:crypto";
2180
+ var adapterVersion = "generation-receipts-v1";
2181
+ var provider = "codex-handoff";
2182
+ var GenerationReceiptError = class extends Error {
2183
+ constructor(message, status = 400) {
2184
+ super(message);
2185
+ this.status = status;
2186
+ }
2187
+ status;
2188
+ };
2189
+ function jobId() {
2190
+ return `gen-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
2191
+ }
2192
+ function quote(value) {
2193
+ return JSON.stringify(value);
2194
+ }
2195
+ function parseJson(value, fallback) {
2196
+ if (!value) return fallback;
2197
+ return JSON.parse(value);
2198
+ }
2199
+ function buildRerollHandoff(project, id, prompt, rootAssetId, target, request) {
2200
+ const importCommand = `npx lineage reroll import --project ${quote(project)} --job-id ${quote(id)} --file <.asset-scratch-file> --confirm-write --json`;
2201
+ return {
2202
+ schema_version: "lineage.generation_handoff.v1",
2203
+ provider,
2204
+ project,
2205
+ job_id: id,
2206
+ prompt,
2207
+ expected_output_count: 1,
2208
+ lineage: {
2209
+ root_asset_id: rootAssetId,
2210
+ parent_asset_id: target.asset_id,
2211
+ selection_strategy: "reroll_request",
2212
+ parent_title: target.title,
2213
+ parent_local_path: target.local_path,
2214
+ parent_s3_key: target.s3_key
2215
+ },
2216
+ instructions: [
2217
+ "Use Codex image generation outside Lineage server code.",
2218
+ "Write the regenerated output file under .asset-scratch before import.",
2219
+ "Do not call live provider APIs from the CLI or server.",
2220
+ "Import exactly one output with reroll import, not link-child or generate image import.",
2221
+ `Resolve re-roll request ${request.id}; do not create a visible lineage child edge.`
2222
+ ],
2223
+ import_command: importCommand,
2224
+ guardrails: { live_generation: false, external_services: false, output_root: ".asset-scratch", confirm_write_required: true }
2225
+ };
2226
+ }
2227
+ function receiptFrom(row) {
2228
+ return {
2229
+ id: String(row.id),
2230
+ job_id: String(row.job_id),
2231
+ receipt_type: row.receipt_type,
2232
+ status: row.status,
2233
+ command: String(row.command),
2234
+ payload: parseJson(String(row.payload_json), null),
2235
+ created_at: String(row.created_at)
2236
+ };
2237
+ }
2238
+ function outputFrom(row) {
2239
+ return {
2240
+ id: String(row.id),
2241
+ job_id: String(row.job_id),
2242
+ project_id: String(row.project_id),
2243
+ output_index: Number(row.output_index),
2244
+ file_path: String(row.file_path),
2245
+ checksum_sha256: String(row.checksum_sha256),
2246
+ size_bytes: Number(row.size_bytes),
2247
+ content_type: String(row.content_type),
2248
+ imported_asset_id: String(row.imported_asset_id),
2249
+ parent_asset_id: String(row.parent_asset_id),
2250
+ imported_at: String(row.imported_at)
2251
+ };
2252
+ }
2253
+ function loadGenerationJob(database, project, id) {
2254
+ const row = database.prepare("select * from generation_jobs where project_id = ? and id = ?").get(project, id);
2255
+ if (!row) throw new GenerationReceiptError(`Unknown generation job: ${id}`, 404);
2256
+ const inputRows = database.prepare("select * from generation_job_inputs where job_id = ? order by position").all(id);
2257
+ const inputs = inputRows.map((input) => ({
2258
+ id: String(input.id),
2259
+ job_id: String(input.job_id),
2260
+ project_id: String(input.project_id),
2261
+ asset_id: String(input.asset_id),
2262
+ root_asset_id: String(input.root_asset_id),
2263
+ role: input.role,
2264
+ position: Number(input.position),
2265
+ selection_strategy: String(input.selection_strategy),
2266
+ selection_snapshot: parseJson(String(input.selection_snapshot_json), {})
2267
+ }));
2268
+ const outputs = database.prepare("select * from generation_job_outputs where job_id = ? order by output_index").all(id).map(outputFrom);
2269
+ const receipts = database.prepare("select * from generation_job_receipts where job_id = ? order by created_at, id").all(id).map(receiptFrom);
2270
+ return {
2271
+ id: String(row.id),
2272
+ project_id: String(row.project_id),
2273
+ provider: row.provider,
2274
+ adapter_version: String(row.adapter_version),
2275
+ source_mode: String(row.source_mode),
2276
+ root_asset_id: String(row.root_asset_id),
2277
+ prompt: String(row.prompt),
2278
+ expected_output_count: Number(row.expected_output_count),
2279
+ status: row.status,
2280
+ output_dir: typeof row.output_dir === "string" ? row.output_dir : void 0,
2281
+ handoff: parseJson(String(row.handoff_json), {}),
2282
+ created_at: String(row.created_at),
2283
+ updated_at: String(row.updated_at),
2284
+ imported_at: typeof row.imported_at === "string" ? row.imported_at : void 0,
2285
+ inputs,
2286
+ outputs,
2287
+ receipts
2288
+ };
2289
+ }
2290
+ function insertReceipt(database, id, type, command, payload) {
2291
+ database.prepare(`
2292
+ insert into generation_job_receipts (id, job_id, receipt_type, status, command, payload_json, created_at)
2293
+ values (?, ?, ?, 'ok', ?, ?, ?)
2294
+ `).run(`${id}:receipt:${type}:${Date.now()}`, id, type, command, JSON.stringify(payload), nowIso());
2295
+ }
2296
+ function planImageReroll(project = defaultProject, fields) {
2297
+ const prompt = fields.prompt.trim();
2298
+ if (!prompt) throw new GenerationReceiptError("Missing --prompt");
2299
+ if (!fields.rootAssetId) throw new GenerationReceiptError("Missing --root");
2300
+ if (!fields.targetAssetId) throw new GenerationReceiptError("Missing --target");
2301
+ const snapshot = getLineageSnapshot(project, fields.rootAssetId);
2302
+ const target = snapshot.nodes.find((node) => node.asset_id === fields.targetAssetId);
2303
+ if (!target) throw new GenerationReceiptError(`Re-roll target is not in lineage: ${fields.targetAssetId}`, 404);
2304
+ const request = listLineageRerollRequests(project, snapshot.root_asset_id).requests.find((item) => item.node_asset_id === fields.targetAssetId);
2305
+ if (!request) throw new GenerationReceiptError(`No pending re-roll request for ${fields.targetAssetId}`);
2306
+ const id = jobId();
2307
+ const timestamp = nowIso();
2308
+ const handoff = buildRerollHandoff(project, id, prompt, snapshot.root_asset_id, target, request);
2309
+ const input = {
2310
+ id: `${id}:input:0`,
2311
+ job_id: id,
2312
+ project_id: project,
2313
+ asset_id: target.asset_id,
2314
+ root_asset_id: snapshot.root_asset_id,
2315
+ role: "reroll_target",
2316
+ position: 0,
2317
+ selection_strategy: "reroll_request",
2318
+ selection_snapshot: {
2319
+ project,
2320
+ root_asset_id: snapshot.root_asset_id,
2321
+ strategy: "selected",
2322
+ selection_mode: "single",
2323
+ recommended_action: "evolve_variations",
2324
+ reason: "user_selected",
2325
+ next_asset: target,
2326
+ next_assets: [target],
2327
+ latest: snapshot.latest,
2328
+ selected: [target.asset_id],
2329
+ selection: null,
2330
+ selections: [],
2331
+ candidates: snapshot.nodes,
2332
+ warnings: ["Re-roll target: import output as an attempt, not a lineage child."],
2333
+ fetchedAt: timestamp
2334
+ }
2335
+ };
2336
+ const preview = {
2337
+ id,
2338
+ project_id: project,
2339
+ provider,
2340
+ adapter_version: adapterVersion,
2341
+ source_mode: "lineage_reroll",
2342
+ root_asset_id: snapshot.root_asset_id,
2343
+ prompt,
2344
+ expected_output_count: 1,
2345
+ status: "planned",
2346
+ output_dir: ".asset-scratch",
2347
+ handoff,
2348
+ created_at: timestamp,
2349
+ updated_at: timestamp,
2350
+ inputs: [input],
2351
+ outputs: [],
2352
+ receipts: [{
2353
+ id: `${id}:receipt:plan:preview`,
2354
+ job_id: id,
2355
+ receipt_type: "plan",
2356
+ status: "ok",
2357
+ command: "reroll plan",
2358
+ payload: { prompt, expected_output_count: 1, lineage: handoff.lineage, reroll_request_id: request.id },
2359
+ created_at: timestamp
2360
+ }]
2361
+ };
2362
+ if (fields.dryRun) return { ok: true, command: "generate image plan", project, dryRun: true, wouldWrite: true, job: preview };
2363
+ const database = lineageDb();
2364
+ try {
2365
+ database.exec("BEGIN IMMEDIATE");
2366
+ try {
2367
+ database.prepare(`
2368
+ insert into generation_jobs (
2369
+ id, project_id, provider, adapter_version, source_mode, root_asset_id, prompt,
2370
+ expected_output_count, status, output_dir, handoff_json, created_at, updated_at
2371
+ ) values (?, ?, ?, ?, 'lineage_reroll', ?, ?, 1, 'planned', ?, ?, ?, ?)
2372
+ `).run(id, project, provider, adapterVersion, snapshot.root_asset_id, prompt, ".asset-scratch", JSON.stringify(handoff), timestamp, timestamp);
2373
+ database.prepare("insert into generation_job_inputs (id, job_id, project_id, asset_id, root_asset_id, role, position, selection_strategy, selection_snapshot_json) values (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(input.id, id, project, input.asset_id, input.root_asset_id, input.role, input.position, input.selection_strategy, JSON.stringify(input.selection_snapshot));
2374
+ insertReceipt(database, id, "plan", "reroll plan", preview.receipts[0].payload);
2375
+ database.exec("COMMIT");
2376
+ } catch (error) {
2377
+ database.exec("ROLLBACK");
2378
+ throw error;
2379
+ }
2380
+ return { ok: true, command: "generate image plan", project, job: loadGenerationJob(database, project, id) };
2381
+ } finally {
2382
+ database.close();
2383
+ }
2384
+ }
2385
+ function isPathInside(child, parent) {
2386
+ const rel = relative2(parent, child);
2387
+ return Boolean(rel) && !rel.startsWith("..") && !rel.startsWith("/");
2388
+ }
2389
+ function resolveScratchFile(file) {
2390
+ const scratchRoot = resolve3(repoRoot, ".asset-scratch");
2391
+ const candidate = file.startsWith(".asset-scratch/") || resolve3(file).startsWith(scratchRoot) ? resolve3(repoRoot, file) : resolve3(scratchRoot, file);
2392
+ if (!isPathInside(candidate, scratchRoot)) throw new GenerationReceiptError(`Import file must be under .asset-scratch: ${file}`);
2393
+ if (!existsSync4(candidate)) throw new GenerationReceiptError(`Missing import file: ${file}`, 404);
2394
+ const realScratchRoot = realpathSync(scratchRoot);
2395
+ const realCandidate = realpathSync(candidate);
2396
+ if (!isPathInside(realCandidate, realScratchRoot)) throw new GenerationReceiptError(`Import file must be under .asset-scratch: ${file}`);
2397
+ const stats = statSync3(candidate);
2398
+ if (!stats.isFile()) throw new GenerationReceiptError(`Import path is not a file: ${file}`);
2399
+ const checksum = fileSha256(candidate);
2400
+ return {
2401
+ relativePath: relative2(scratchRoot, candidate),
2402
+ checksum,
2403
+ size: stats.size,
2404
+ contentType: contentTypeFor(candidate),
2405
+ assetId: `local-${checksum.slice(0, 12)}`
2406
+ };
2407
+ }
2408
+ function importImageRerollOutput(project = defaultProject, fields) {
2409
+ if (!fields.jobId) throw new GenerationReceiptError("Missing --job-id");
2410
+ if (!fields.confirmWrite) throw new GenerationReceiptError("Generation import requires --confirm-write");
2411
+ const database = lineageDb();
2412
+ let job;
2413
+ try {
2414
+ job = loadGenerationJob(database, project, fields.jobId);
2415
+ } finally {
2416
+ database.close();
2417
+ }
2418
+ if (job.status !== "planned") throw new GenerationReceiptError(`Generation job is not importable from status: ${job.status}`);
2419
+ if (job.source_mode !== "lineage_reroll") throw new GenerationReceiptError(`Generation job is not a re-roll job: ${job.source_mode}`);
2420
+ const target = job.inputs.filter((input) => input.role === "reroll_target");
2421
+ if (target.length !== 1) throw new GenerationReceiptError("Re-roll import requires exactly one reroll_target input");
2422
+ const resolved = resolveScratchFile(fields.file);
2423
+ indexLineageAssets(project);
2424
+ const writeDb = lineageDb();
2425
+ try {
2426
+ const timestamp = nowIso();
2427
+ writeDb.exec("BEGIN IMMEDIATE");
2428
+ try {
2429
+ const assetRow = writeDb.prepare("select id from assets where project_id = ? and id = ?").get(project, resolved.assetId);
2430
+ if (!assetRow) throw new GenerationReceiptError(`Indexed local asset was not found: ${resolved.relativePath}`);
2431
+ const outputId = `${fields.jobId}:output:0`;
2432
+ writeDb.prepare(`insert into generation_job_outputs (
2433
+ id, job_id, project_id, output_index, file_path, checksum_sha256, size_bytes, content_type, imported_asset_id, parent_asset_id, imported_at
2434
+ ) values (?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`).run(outputId, fields.jobId, project, resolved.relativePath, resolved.checksum, resolved.size, resolved.contentType, resolved.assetId, target[0].asset_id, timestamp);
2435
+ writeDb.prepare(`
2436
+ update generation_jobs
2437
+ set status = 'imported', imported_at = ?, updated_at = ?
2438
+ where project_id = ? and id = ?
2439
+ `).run(timestamp, timestamp, project, fields.jobId);
2440
+ insertReceipt(writeDb, fields.jobId, "import", "reroll import", {
2441
+ file: { output_index: 0, file_path: resolved.relativePath, imported_asset_id: resolved.assetId, parent_asset_id: target[0].asset_id },
2442
+ reroll: { root_asset_id: job.root_asset_id, node_asset_id: target[0].asset_id }
2443
+ });
2444
+ writeDb.exec("COMMIT");
2445
+ } catch (error) {
2446
+ writeDb.exec("ROLLBACK");
2447
+ throw error;
2448
+ }
2449
+ recordLineageRerollAttempt(project, {
2450
+ rootAssetId: job.root_asset_id,
2451
+ nodeAssetId: target[0].asset_id,
2452
+ assetId: resolved.assetId,
2453
+ prompt: job.prompt,
2454
+ generationJobId: fields.jobId,
2455
+ filePath: resolved.relativePath,
2456
+ checksumSha256: resolved.checksum,
2457
+ confirmWrite: true
2458
+ });
2459
+ const importedJob = loadGenerationJob(writeDb, project, fields.jobId);
2460
+ return { ok: true, command: "generate image import", project, job: importedJob, imported: importedJob.outputs };
2461
+ } finally {
2462
+ writeDb.close();
2463
+ }
2464
+ }
2465
+
1682
2466
  // src/cli/lineageCli.ts
1683
2467
  var signalExitCodes = {
1684
2468
  SIGINT: 130,
1685
2469
  SIGTERM: 143
1686
2470
  };
1687
2471
  function packageRoot() {
1688
- return resolve3(dirname3(fileURLToPath2(import.meta.url)), "..", "..");
2472
+ return resolve4(dirname3(fileURLToPath2(import.meta.url)), "..", "..");
1689
2473
  }
1690
2474
  function packageVersion() {
1691
2475
  try {
1692
- const packageInfo = JSON.parse(readFileSync3(join5(packageRoot(), "package.json"), "utf8"));
2476
+ const packageInfo = JSON.parse(readFileSync3(join6(packageRoot(), "package.json"), "utf8"));
1693
2477
  return packageInfo.version || "0.0.0";
1694
2478
  } catch {
1695
2479
  return "0.0.0";
@@ -1697,9 +2481,9 @@ function packageVersion() {
1697
2481
  }
1698
2482
  function dataRoot(displayName) {
1699
2483
  if (process.env.LINEAGE_HOME) return process.env.LINEAGE_HOME;
1700
- if (platform() === "darwin") return join5(homedir(), "Library", "Application Support", displayName);
1701
- if (platform() === "win32") return join5(process.env.APPDATA || join5(homedir(), "AppData", "Roaming"), displayName);
1702
- return join5(process.env.XDG_DATA_HOME || join5(homedir(), ".local", "share"), displayName.toLowerCase().replace(/\s+/g, "-"));
2484
+ if (platform() === "darwin") return join6(homedir(), "Library", "Application Support", displayName);
2485
+ if (platform() === "win32") return join6(process.env.APPDATA || join6(homedir(), "AppData", "Roaming"), displayName);
2486
+ return join6(process.env.XDG_DATA_HOME || join6(homedir(), ".local", "share"), displayName.toLowerCase().replace(/\s+/g, "-"));
1703
2487
  }
1704
2488
  function readOption(args, name) {
1705
2489
  const prefix = `${name}=`;
@@ -1717,7 +2501,7 @@ function resolveStartOptions(config, args) {
1717
2501
  throw new Error(`Invalid port: ${rawPort}`);
1718
2502
  }
1719
2503
  return {
1720
- dbPath: readOption(args, "--db") || process.env.LINEAGE_DB || join5(runtimeDir, `${config.binName}.sqlite`),
2504
+ dbPath: readOption(args, "--db") || process.env.LINEAGE_DB || join6(runtimeDir, `${config.binName}.sqlite`),
1721
2505
  host: readOption(args, "--host") || process.env.HOST || config.defaultHost,
1722
2506
  json: args.includes("--json"),
1723
2507
  open: args.includes("--open"),
@@ -1733,6 +2517,11 @@ Usage:
1733
2517
  ${config.binName} brief [--project <project>] [--root <asset-id>] [--db <path>] [--json]
1734
2518
  ${config.binName} inspect --asset-id <asset-id> [--project <project>] [--db <path>] [--json]
1735
2519
  ${config.binName} link-child --root <asset-id> --child <asset-id> [--project <project>] [--claim-token <claim-id.secret>] [--confirm-write] [--db <path>] [--json]
2520
+ ${config.binName} reroll list --root <asset-id> [--project <project>] [--db <path>] [--json]
2521
+ ${config.binName} reroll mark --root <asset-id> --target <asset-id> [--notes <text>] [--requested-by agent|human|system] [--project <project>] [--confirm-write] [--db <path>] [--json]
2522
+ ${config.binName} reroll cancel --root <asset-id> --target <asset-id> [--project <project>] [--confirm-write] [--db <path>] [--json]
2523
+ ${config.binName} reroll plan --root <asset-id> --target <asset-id> --prompt <text> [--project <project>] [--db <path>] [--json]
2524
+ ${config.binName} reroll import --job-id <job-id> --file <scratch-file> --confirm-write [--project <project>] [--db <path>] [--json]
1736
2525
  ${config.binName} agent claim --project <project> --scope <scope> --target <target-id> --agent-name <name> [--channel <channel>] [--ttl 20m] [--json]
1737
2526
  ${config.binName} agent graph --root <asset-id> [--project <project>] [--db <path>] [--json]
1738
2527
  ${config.binName} agent status [--project <project>] [--json]
@@ -1796,8 +2585,63 @@ function runLineageDataCommand(command, args) {
1796
2585
  rootAssetId: options.rootAssetId || options.assetId
1797
2586
  });
1798
2587
  }
2588
+ if (command === "reroll") {
2589
+ const subcommand = positionalArgs(args)[0] || "";
2590
+ if (subcommand === "list") {
2591
+ if (!options.rootAssetId) throw new Error("lineage reroll list requires --root");
2592
+ return listLineageRerollRequests(options.project, options.rootAssetId);
2593
+ }
2594
+ if (subcommand === "mark") {
2595
+ const targetAssetId = readOption(args, "--target");
2596
+ const requestedBy = rerollRequestedBy(readOption(args, "--requested-by") || "agent");
2597
+ if (!options.rootAssetId) throw new Error("lineage reroll mark requires --root");
2598
+ if (!targetAssetId) throw new Error("lineage reroll mark requires --target");
2599
+ return markLineageRerollRequest(options.project, {
2600
+ rootAssetId: options.rootAssetId,
2601
+ nodeAssetId: targetAssetId,
2602
+ notes: readOption(args, "--notes"),
2603
+ requestedBy,
2604
+ confirmWrite: options.confirmWrite
2605
+ });
2606
+ }
2607
+ if (subcommand === "cancel") {
2608
+ const targetAssetId = readOption(args, "--target");
2609
+ if (!options.rootAssetId) throw new Error("lineage reroll cancel requires --root");
2610
+ if (!targetAssetId) throw new Error("lineage reroll cancel requires --target");
2611
+ return clearLineageRerollRequest(options.project, {
2612
+ rootAssetId: options.rootAssetId,
2613
+ nodeAssetId: targetAssetId,
2614
+ confirmWrite: options.confirmWrite
2615
+ });
2616
+ }
2617
+ if (subcommand === "plan") {
2618
+ const targetAssetId = readOption(args, "--target");
2619
+ const prompt = readOption(args, "--prompt");
2620
+ if (!options.rootAssetId) throw new Error("lineage reroll plan requires --root");
2621
+ if (!targetAssetId) throw new Error("lineage reroll plan requires --target");
2622
+ if (!prompt) throw new Error("lineage reroll plan requires --prompt");
2623
+ return planImageReroll(options.project, {
2624
+ rootAssetId: options.rootAssetId,
2625
+ targetAssetId,
2626
+ prompt,
2627
+ dryRun: args.includes("--dry-run")
2628
+ });
2629
+ }
2630
+ if (subcommand === "import") {
2631
+ const jobId2 = readOption(args, "--job-id");
2632
+ const file = readOption(args, "--file");
2633
+ if (!jobId2) throw new Error("lineage reroll import requires --job-id");
2634
+ if (!file) throw new Error("lineage reroll import requires --file");
2635
+ return importImageRerollOutput(options.project, { jobId: jobId2, file, confirmWrite: options.confirmWrite });
2636
+ }
2637
+ throw new Error(`Unknown reroll command: ${subcommand}`);
2638
+ }
1799
2639
  throw new Error(`Unknown command: ${command}`);
1800
2640
  }
2641
+ function rerollRequestedBy(value) {
2642
+ if (value === "agent" || value === "human" || value === "system") return value;
2643
+ throw new Error(`Invalid re-roll requester: ${value}`);
2644
+ }
1801
2645
  function printDataResult(command, result, json) {
1802
2646
  if (json) {
1803
2647
  console.log(JSON.stringify(result, null, 2));
@@ -1826,6 +2670,24 @@ function printDataResult(command, result, json) {
1826
2670
  console.log(link.message || `${link.dryRun ? "Dry run: " : ""}Link ${link.edge?.child_asset_id || "child"} from ${link.edge?.parent_asset_id || "parent"}`);
1827
2671
  return;
1828
2672
  }
2673
+ if (command === "reroll" && result && typeof result === "object") {
2674
+ if ("requests" in result) {
2675
+ const listed = result;
2676
+ console.log(`${listed.requests.length} pending re-roll target(s)`);
2677
+ for (const request of listed.requests) console.log(`${request.node_asset_id}${request.notes ? `: ${request.notes}` : ""}`);
2678
+ return;
2679
+ }
2680
+ if ("job" in result) {
2681
+ const planned = result;
2682
+ console.log(planned.imported ? `Imported re-roll for ${planned.job?.id || "job"}` : `Planned re-roll ${planned.job?.id || "job"}`);
2683
+ return;
2684
+ }
2685
+ if ("request" in result) {
2686
+ const mutation = result;
2687
+ console.log(`${mutation.dryRun ? "Dry run: " : ""}Re-roll ${mutation.request?.status || "request"} for ${mutation.request?.node_asset_id || "target"}`);
2688
+ return;
2689
+ }
2690
+ }
1829
2691
  console.log(String(result));
1830
2692
  }
1831
2693
  function runLineageAgentCommand(command, args) {
@@ -1954,8 +2816,8 @@ function start(config, args) {
1954
2816
  else console.error(`${config.binName}: ${message}`);
1955
2817
  process.exit(1);
1956
2818
  }
1957
- const serverPath = join5(packageRoot(), "dist", "server.js");
1958
- if (!existsSync4(serverPath)) {
2819
+ const serverPath = join6(packageRoot(), "dist", "server.js");
2820
+ if (!existsSync5(serverPath)) {
1959
2821
  const message = `Missing bundled server at ${serverPath}. Run npm run build before using ${config.binName} start from a source checkout.`;
1960
2822
  if (options.json) console.error(JSON.stringify({ ok: false, error: message }, null, 2));
1961
2823
  else console.error(`${config.binName}: ${message}`);
@@ -2009,7 +2871,7 @@ function runLineageCli(config, args = process.argv.slice(2)) {
2009
2871
  start(config, normalizedArgs.slice(1));
2010
2872
  return;
2011
2873
  }
2012
- if (command === "next" || command === "brief" || command === "inspect" || command === "link-child") {
2874
+ if (command === "next" || command === "brief" || command === "inspect" || command === "link-child" || command === "reroll") {
2013
2875
  const commandArgs = normalizedArgs.slice(1);
2014
2876
  const json2 = commandArgs.includes("--json");
2015
2877
  try {