@mean-weasel/lineage 0.1.4 → 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.
package/dist/server.js CHANGED
@@ -339,6 +339,44 @@ function lineageDb() {
339
339
  );
340
340
  create index if not exists edges_parent on asset_edges(project_id, parent_asset_id);
341
341
  create index if not exists edges_child on asset_edges(project_id, child_asset_id);
342
+ create table if not exists asset_attempts (
343
+ id text primary key,
344
+ project_id text not null references projects(id),
345
+ node_asset_id text not null references assets(id),
346
+ asset_id text not null references assets(id),
347
+ attempt_index integer not null check (attempt_index > 0),
348
+ source text not null check (source in ('initial', 'generated_child', 'reroll')),
349
+ prompt text,
350
+ generation_job_id text,
351
+ file_path text,
352
+ checksum_sha256 text,
353
+ created_at text not null,
354
+ promoted_at text,
355
+ is_current integer not null check (is_current in (0, 1)),
356
+ unique(project_id, node_asset_id, attempt_index),
357
+ unique(project_id, node_asset_id, asset_id, source)
358
+ );
359
+ create unique index if not exists asset_attempts_one_current
360
+ on asset_attempts(project_id, node_asset_id)
361
+ where is_current = 1;
362
+ create index if not exists asset_attempts_node_created
363
+ on asset_attempts(project_id, node_asset_id, created_at);
364
+ create table if not exists asset_reroll_requests (
365
+ id text primary key,
366
+ project_id text not null references projects(id),
367
+ root_asset_id text not null references assets(id),
368
+ node_asset_id text not null references assets(id),
369
+ status text not null check (status in ('pending', 'resolved', 'cancelled')),
370
+ requested_by text not null check (requested_by in ('human', 'agent', 'system')),
371
+ notes text,
372
+ created_at text not null,
373
+ resolved_at text
374
+ );
375
+ create unique index if not exists asset_reroll_requests_one_pending
376
+ on asset_reroll_requests(project_id, root_asset_id, node_asset_id)
377
+ where status = 'pending';
378
+ create index if not exists asset_reroll_requests_root_status
379
+ on asset_reroll_requests(project_id, root_asset_id, status, created_at);
342
380
  create table if not exists asset_reviews (
343
381
  asset_id text primary key references assets(id),
344
382
  review_state text not null check (review_state in ('unreviewed', 'approved', 'needs_revision', 'rejected', 'ignored')),
@@ -543,7 +581,7 @@ function lineageDb() {
543
581
  project_id text not null references projects(id),
544
582
  provider text not null default 'codex-handoff',
545
583
  adapter_version text not null,
546
- source_mode text not null check (source_mode in ('lineage_selection')),
584
+ source_mode text not null check (source_mode in ('lineage_selection', 'lineage_reroll')),
547
585
  root_asset_id text not null references assets(id),
548
586
  prompt text not null,
549
587
  expected_output_count integer not null check (expected_output_count > 0),
@@ -561,7 +599,7 @@ function lineageDb() {
561
599
  project_id text not null references projects(id),
562
600
  asset_id text not null references assets(id),
563
601
  root_asset_id text not null references assets(id),
564
- role text not null check (role in ('lineage_next_base', 'reference')),
602
+ role text not null check (role in ('lineage_next_base', 'reference', 'reroll_target')),
565
603
  position integer not null,
566
604
  selection_strategy text not null,
567
605
  selection_snapshot_json text not null,
@@ -1587,755 +1625,1099 @@ function activeLineageWorkspaceRoot(project) {
1587
1625
  }
1588
1626
  }
1589
1627
 
1590
- // src/server/assetLineage.ts
1591
- var LineageError = class extends Error {
1592
- constructor(message, status = 400) {
1628
+ // src/server/agentClaims.ts
1629
+ import { createHash as createHash2, randomBytes, timingSafeEqual } from "node:crypto";
1630
+ var defaultTtlSeconds = 20 * 60;
1631
+ var idleAfterSeconds = 5 * 60;
1632
+ var staleAfterSeconds = 15 * 60;
1633
+ var scopes = /* @__PURE__ */ new Set(["lineage_workspace", "content_post", "content_queue_lane", "selection_set", "project_channel"]);
1634
+ var AgentClaimError = class extends Error {
1635
+ constructor(message, status = 400, code = "agent_claim_error", conflicts = []) {
1593
1636
  super(message);
1594
1637
  this.status = status;
1638
+ this.code = code;
1639
+ this.conflicts = conflicts;
1595
1640
  }
1596
1641
  status;
1642
+ code;
1643
+ conflicts;
1597
1644
  };
1598
- function isLineageError(error) {
1599
- return error instanceof LineageError;
1645
+ function isAgentClaimError(error) {
1646
+ return error instanceof AgentClaimError;
1600
1647
  }
1601
- function collectAssets(project, source) {
1602
- const first = listAssets(project, { source, page: 1, pageSize: 100 });
1603
- const assets = [...first.assets];
1604
- for (let page = 2; page <= first.pagination.totalPages; page += 1) {
1605
- assets.push(...listAssets(project, { source, page, pageSize: 100 }).assets);
1648
+ function parseClaimTtl(value) {
1649
+ if (!value) return defaultTtlSeconds;
1650
+ const match = value.trim().match(/^(\d+)(s|m|h)?$/);
1651
+ if (!match) throw new AgentClaimError(`Invalid claim ttl: ${value}`);
1652
+ const amount = Number(match[1]);
1653
+ const unit = match[2] || "s";
1654
+ const multiplier = unit === "h" ? 3600 : unit === "m" ? 60 : 1;
1655
+ const seconds = amount * multiplier;
1656
+ if (!Number.isInteger(seconds) || seconds < 30 || seconds > 24 * 60 * 60) {
1657
+ throw new AgentClaimError("Claim ttl must be between 30 seconds and 24 hours");
1606
1658
  }
1607
- return assets;
1659
+ return seconds;
1608
1660
  }
1609
- function upsertProject(database, project) {
1661
+ function randomId(prefix) {
1662
+ return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString("base64url").toLowerCase()}`;
1663
+ }
1664
+ function tokenHash(token) {
1665
+ return createHash2("sha256").update(token).digest("hex");
1666
+ }
1667
+ function safeEqual(a, b) {
1668
+ const left = Buffer.from(a);
1669
+ const right = Buffer.from(b);
1670
+ return left.length === right.length && timingSafeEqual(left, right);
1671
+ }
1672
+ function expiresAtFrom(timestamp, ttlSeconds) {
1673
+ return new Date(new Date(timestamp).getTime() + ttlSeconds * 1e3).toISOString();
1674
+ }
1675
+ function metadataJson(metadata2) {
1676
+ return metadata2 ? JSON.stringify(metadata2) : null;
1677
+ }
1678
+ function parseMetadata(value) {
1679
+ if (typeof value !== "string" || !value) return void 0;
1680
+ try {
1681
+ const parsed = JSON.parse(value);
1682
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1683
+ } catch {
1684
+ return void 0;
1685
+ }
1686
+ }
1687
+ function derivedState(row, now = /* @__PURE__ */ new Date()) {
1688
+ if (row.status !== "active") return row.status === "expired" ? "expired" : "stale";
1689
+ if (new Date(row.expires_at).getTime() <= now.getTime()) return "expired";
1690
+ const ageSeconds = Math.max(0, Math.floor((now.getTime() - new Date(row.heartbeat_at).getTime()) / 1e3));
1691
+ if (ageSeconds >= staleAfterSeconds) return "stale";
1692
+ if (ageSeconds >= idleAfterSeconds) return "idle";
1693
+ return "active";
1694
+ }
1695
+ function rowToClaim(row, now = /* @__PURE__ */ new Date()) {
1696
+ const heartbeatAt = String(row.heartbeat_at);
1697
+ return {
1698
+ id: String(row.id),
1699
+ project: String(row.project_id),
1700
+ channel: typeof row.channel === "string" ? row.channel : void 0,
1701
+ scope_type: String(row.scope_type),
1702
+ target_id: String(row.target_id),
1703
+ target_title: typeof row.target_title === "string" ? row.target_title : void 0,
1704
+ agent_id: typeof row.agent_id === "string" ? row.agent_id : void 0,
1705
+ agent_name: String(row.agent_name),
1706
+ agent_kind: String(row.agent_kind),
1707
+ thread_id: typeof row.thread_id === "string" ? row.thread_id : void 0,
1708
+ status: String(row.status),
1709
+ created_at: String(row.created_at),
1710
+ heartbeat_at: heartbeatAt,
1711
+ expires_at: String(row.expires_at),
1712
+ released_at: typeof row.released_at === "string" ? row.released_at : void 0,
1713
+ revoked_at: typeof row.revoked_at === "string" ? row.revoked_at : void 0,
1714
+ revoked_by: typeof row.revoked_by === "string" ? row.revoked_by : void 0,
1715
+ override_reason: typeof row.override_reason === "string" ? row.override_reason : void 0,
1716
+ metadata: parseMetadata(row.metadata_json),
1717
+ heartbeat_age_seconds: Math.max(0, Math.floor((now.getTime() - new Date(heartbeatAt).getTime()) / 1e3)),
1718
+ derived_state: derivedState({
1719
+ expires_at: String(row.expires_at),
1720
+ heartbeat_at: heartbeatAt,
1721
+ status: String(row.status)
1722
+ }, now)
1723
+ };
1724
+ }
1725
+ function eventToRow(row) {
1726
+ return {
1727
+ claim_id: String(row.claim_id),
1728
+ event_type: String(row.event_type),
1729
+ actor: typeof row.actor === "string" ? row.actor : void 0,
1730
+ message: typeof row.message === "string" ? row.message : void 0,
1731
+ created_at: String(row.created_at),
1732
+ metadata: parseMetadata(row.metadata_json)
1733
+ };
1734
+ }
1735
+ function ensureProject3(database, project) {
1610
1736
  const timestamp = nowIso();
1611
1737
  database.prepare(`
1612
- insert into projects (id, product, catalog_path, created_at, updated_at)
1613
- values (?, ?, ?, ?, ?)
1738
+ insert into projects (id, product, created_at, updated_at)
1739
+ values (?, ?, ?, ?)
1614
1740
  on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
1615
- `).run(project, project, join5(repoRoot, project, "assets", "catalog.json"), timestamp, timestamp);
1741
+ `).run(project, project, timestamp, timestamp);
1616
1742
  }
1617
- function upsertAsset(database, project, asset) {
1618
- const timestamp = nowIso();
1619
- const source = asset.source === "local" ? "local" : "catalog";
1620
- database.prepare(`
1621
- insert into assets (
1622
- id, project_id, source, local_path, s3_key, checksum_sha256, media_type, title, status,
1623
- channel, campaign, audience, size_bytes, content_type, created_at, updated_at, last_seen_at
1624
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1625
- on conflict(id) do update set
1626
- source = excluded.source, local_path = excluded.local_path, s3_key = excluded.s3_key,
1627
- checksum_sha256 = excluded.checksum_sha256, media_type = excluded.media_type,
1628
- title = excluded.title, status = excluded.status, channel = excluded.channel,
1629
- campaign = excluded.campaign, audience = excluded.audience, size_bytes = excluded.size_bytes,
1630
- content_type = excluded.content_type, updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
1631
- `).run(
1632
- asset.asset_id,
1633
- project,
1634
- source,
1635
- asset.local?.relative_path || null,
1636
- asset.s3?.key || null,
1637
- asset.local?.checksum_sha256 || asset.s3?.checksum_sha256 || null,
1638
- asset.content_type,
1639
- asset.title,
1640
- asset.status,
1641
- asset.channel || null,
1642
- asset.campaign || null,
1643
- asset.audience || null,
1644
- asset.local?.size_bytes || asset.s3?.size_bytes || null,
1645
- asset.local?.content_type || asset.s3?.content_type || null,
1646
- timestamp,
1647
- timestamp,
1648
- timestamp
1649
- );
1743
+ function recordEvent(database, claimId, eventType, actor, message, metadata2) {
1650
1744
  database.prepare(`
1651
- insert into asset_reviews (asset_id, review_state, updated_at)
1652
- values (?, 'unreviewed', ?)
1653
- on conflict(asset_id) do nothing
1654
- `).run(asset.asset_id, timestamp);
1745
+ insert into agent_claim_events (id, claim_id, event_type, actor, message, created_at, metadata_json)
1746
+ values (?, ?, ?, ?, ?, ?, ?)
1747
+ `).run(randomId("claim_event"), claimId, eventType, actor || null, message || null, nowIso(), metadataJson(metadata2));
1655
1748
  }
1656
- function indexLineageAssets(project = defaultProject) {
1657
- const database = lineageDb();
1658
- const catalog = collectAssets(project, "catalog");
1659
- const local = collectAssets(project, "local");
1660
- upsertProject(database, project);
1661
- for (const asset of [...catalog, ...local]) upsertAsset(database, project, asset);
1662
- database.close();
1663
- return { catalog: catalog.length, local: local.length, total: catalog.length + local.length, database: lineageDbPath() };
1749
+ function expireActiveClaims(database) {
1750
+ const timestamp = nowIso();
1751
+ const expired = database.prepare(`
1752
+ select id from agent_claims where status = 'active' and expires_at <= ?
1753
+ `).all(timestamp);
1754
+ if (expired.length === 0) return;
1755
+ database.prepare(`
1756
+ update agent_claims set status = 'expired' where status = 'active' and expires_at <= ?
1757
+ `).run(timestamp);
1758
+ for (const claim of expired) recordEvent(database, claim.id, "expired", "system", "Claim expired after missed heartbeat.");
1664
1759
  }
1665
- function requireAsset2(database, project, assetId) {
1666
- const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
1667
- if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
1760
+ function normalizeScope(scopeType) {
1761
+ if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
1762
+ return scopeType;
1668
1763
  }
1669
- function parentOf(database, project, assetId) {
1670
- const row = database.prepare("select parent_asset_id from asset_edges where project_id = ? and child_asset_id = ? order by created_at limit 1").get(project, assetId);
1671
- return row?.parent_asset_id;
1764
+ function channelOverlaps(left, right) {
1765
+ return !left || !right || left === right;
1672
1766
  }
1673
- function rootFor(database, project, assetId) {
1674
- let root = assetId;
1675
- const seen = /* @__PURE__ */ new Set();
1676
- while (!seen.has(root)) {
1677
- seen.add(root);
1678
- const parent = parentOf(database, project, root);
1679
- if (!parent) return root;
1680
- root = parent;
1681
- }
1682
- return assetId;
1767
+ function claimOverlaps(candidate, existing) {
1768
+ if (candidate.project !== existing.project) return false;
1769
+ if (!channelOverlaps(candidate.channel, existing.channel)) return false;
1770
+ if (candidate.scope_type === existing.scope_type && candidate.target_id === existing.target_id) return true;
1771
+ return candidate.scope_type === "project_channel" || existing.scope_type === "project_channel";
1683
1772
  }
1684
- function latestSelectedRoot(database, project) {
1685
- const row = database.prepare("select root_asset_id from asset_selections where project_id = ? order by selected_at desc limit 1").get(project);
1686
- return row?.root_asset_id;
1773
+ function activeClaims(database, project) {
1774
+ const rows = project ? database.prepare("select * from agent_claims where project_id = ? and status = 'active' order by heartbeat_at desc").all(project) : database.prepare("select * from agent_claims where status = 'active' order by project_id, heartbeat_at desc").all();
1775
+ return rows.map((row) => rowToClaim(row));
1687
1776
  }
1688
- function resolveRoot(database, project, rootAssetId) {
1689
- if (rootAssetId) {
1690
- requireAsset2(database, project, rootAssetId);
1691
- return rootAssetId;
1692
- }
1693
- const root = activeLineageWorkspaceRoot(project) || latestSelectedRoot(database, project);
1694
- if (!root) throw new LineageError("Lineage command requires --root unless a project selection exists");
1695
- requireAsset2(database, project, root);
1696
- return root;
1777
+ function findClaimById(database, claimId, project) {
1778
+ const row = project ? database.prepare("select * from agent_claims where project_id = ? and id = ?").get(project, claimId) : database.prepare("select * from agent_claims where id = ?").get(claimId);
1779
+ return row ? rowToClaim(row) : null;
1697
1780
  }
1698
- function edgeId(project, parent, child) {
1699
- return `${project}:${parent}:derived_from:${child}`;
1781
+ function findClaimRowByToken(database, claimToken) {
1782
+ const claimId = claimToken.split(".")[0];
1783
+ const row = database.prepare("select * from agent_claims where id = ?").get(claimId);
1784
+ if (!row) return null;
1785
+ return safeEqual(String(row.token_hash), tokenHash(claimToken)) ? row : null;
1700
1786
  }
1701
- function canPreviewLocally(mediaType, localPath) {
1702
- return Boolean(localPath && ["image", "video", "gif"].includes(mediaType));
1787
+ function denied(code, message, conflicts = []) {
1788
+ return { ok: false, code, message, conflicts };
1703
1789
  }
1704
- function localPreviewUrl(project, localPath) {
1705
- if (!localPath) return void 0;
1706
- const params = new URLSearchParams({ project, path: localPath });
1707
- return `/api/assets/local-preview?${params.toString()}`;
1790
+ function scopeAllowsWrite(claim, scopeType, targetId, writeKind) {
1791
+ if (claim.scope_type === scopeType && claim.target_id === targetId) return true;
1792
+ if (claim.scope_type === "project_channel") return true;
1793
+ if (claim.scope_type === "lineage_workspace" && scopeType === "lineage_workspace") return claim.target_id === targetId;
1794
+ if (claim.scope_type === "content_queue_lane" && writeKind === "content_queue_next") return true;
1795
+ return false;
1708
1796
  }
1709
- function linkLineageAssets(project, fields) {
1710
- const database = lineageDb();
1711
- requireAsset2(database, project, fields.parentAssetId);
1712
- requireAsset2(database, project, fields.childAssetId);
1713
- if (fields.parentAssetId === fields.childAssetId) throw new LineageError("Lineage link cannot point to itself");
1714
- const edge = {
1715
- id: edgeId(project, fields.parentAssetId, fields.childAssetId),
1716
- parent_asset_id: fields.parentAssetId,
1717
- child_asset_id: fields.childAssetId,
1718
- relation_type: "derived_from",
1719
- created_at: nowIso()
1720
- };
1721
- if (!fields.confirmWrite) {
1797
+ function createAgentClaim(fields) {
1798
+ const project = fields.project.trim();
1799
+ const targetId = fields.targetId.trim();
1800
+ const agentName = fields.agentName.trim();
1801
+ if (!project) throw new AgentClaimError("Agent claim requires project");
1802
+ if (!targetId) throw new AgentClaimError("Agent claim requires target");
1803
+ if (!agentName) throw new AgentClaimError("Agent claim requires agent name");
1804
+ const scopeType = normalizeScope(fields.scopeType);
1805
+ const ttlSeconds = fields.ttlSeconds || defaultTtlSeconds;
1806
+ const database = lineageDb();
1807
+ try {
1808
+ ensureProject3(database, project);
1809
+ expireActiveClaims(database);
1810
+ const candidate = { project, channel: fields.channel?.trim() || void 0, scope_type: scopeType, target_id: targetId };
1811
+ const conflicts = activeClaims(database, project).filter((claim2) => claimOverlaps(candidate, claim2));
1812
+ if (conflicts.length > 0 && !fields.force) {
1813
+ throw new AgentClaimError("Target already has an active overlapping agent claim.", 409, "target_already_claimed", conflicts);
1814
+ }
1815
+ if (conflicts.length > 0 && !fields.reason?.trim()) {
1816
+ throw new AgentClaimError("Overriding an active claim requires --reason.", 400, "override_reason_required", conflicts);
1817
+ }
1818
+ const timestamp = nowIso();
1819
+ for (const conflict of conflicts) {
1820
+ database.prepare(`
1821
+ update agent_claims
1822
+ set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
1823
+ where id = ? and status = 'active'
1824
+ `).run(timestamp, agentName, fields.reason || null, conflict.id);
1825
+ recordEvent(database, conflict.id, "revoked", agentName, fields.reason || "Revoked by forced claim takeover.");
1826
+ recordEvent(database, conflict.id, "conflict", agentName, `Overridden by ${agentName}.`, { new_claim_target: targetId });
1827
+ }
1828
+ const id = randomId("claim");
1829
+ const secret = randomBytes(24).toString("base64url");
1830
+ const claimToken = `${id}.${secret}`;
1831
+ const expiresAt = expiresAtFrom(timestamp, ttlSeconds);
1832
+ database.prepare(`
1833
+ insert into agent_claims (
1834
+ id, token_hash, project_id, channel, scope_type, target_id, target_title, agent_id, agent_name,
1835
+ agent_kind, thread_id, status, created_at, heartbeat_at, expires_at, metadata_json
1836
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
1837
+ `).run(
1838
+ id,
1839
+ tokenHash(claimToken),
1840
+ project,
1841
+ candidate.channel || null,
1842
+ scopeType,
1843
+ targetId,
1844
+ fields.targetTitle?.trim() || null,
1845
+ fields.agentId?.trim() || null,
1846
+ agentName,
1847
+ fields.agentKind?.trim() || "codex",
1848
+ fields.threadId?.trim() || null,
1849
+ timestamp,
1850
+ timestamp,
1851
+ expiresAt,
1852
+ metadataJson(fields.metadata)
1853
+ );
1854
+ recordEvent(database, id, "created", agentName, `Claimed ${scopeType} ${targetId}.`, { ttl_seconds: ttlSeconds });
1855
+ const claim = findClaimById(database, id);
1856
+ return { ok: true, claim, claim_token: claimToken, conflicts_revoked: conflicts.map((conflict) => conflict.id) };
1857
+ } finally {
1722
1858
  database.close();
1723
- return { ok: true, dryRun: true, edge };
1724
1859
  }
1725
- database.prepare(`
1726
- insert into asset_edges (id, project_id, parent_asset_id, child_asset_id, relation_type, created_at)
1727
- values (?, ?, ?, ?, 'derived_from', ?)
1728
- on conflict(project_id, parent_asset_id, child_asset_id, relation_type) do nothing
1729
- `).run(edge.id, project, edge.parent_asset_id, edge.child_asset_id, edge.created_at);
1730
- database.close();
1731
- return { ok: true, message: `Linked ${edge.child_asset_id} from ${edge.parent_asset_id}`, edge };
1732
1860
  }
1733
- function descendants(database, project, root) {
1734
- const edges = [];
1735
- const queue = [root];
1736
- const seen = /* @__PURE__ */ new Set();
1737
- while (queue.length > 0) {
1738
- const parent = queue.shift();
1739
- if (seen.has(parent)) continue;
1740
- seen.add(parent);
1741
- const rows = database.prepare("select id, parent_asset_id, child_asset_id, relation_type, created_at from asset_edges where project_id = ? and parent_asset_id = ? order by created_at").all(project, parent);
1742
- edges.push(...rows);
1743
- queue.push(...rows.map((row) => row.child_asset_id));
1861
+ function listAgentClaims(project) {
1862
+ const database = lineageDb();
1863
+ try {
1864
+ expireActiveClaims(database);
1865
+ const rows = project ? database.prepare("select * from agent_claims where project_id = ? order by status, heartbeat_at desc").all(project) : database.prepare("select * from agent_claims order by project_id, status, heartbeat_at desc").all();
1866
+ return { ok: true, claims: rows.map((row) => rowToClaim(row)), fetchedAt: nowIso() };
1867
+ } finally {
1868
+ database.close();
1744
1869
  }
1745
- return edges;
1746
1870
  }
1747
- function getLineageSnapshot(project, assetId) {
1871
+ function inspectAgentClaim(claimId, project) {
1748
1872
  const database = lineageDb();
1749
- requireAsset2(database, project, assetId);
1750
- const root = rootFor(database, project, assetId);
1751
- const edges = descendants(database, project, root);
1752
- const ids = [.../* @__PURE__ */ new Set([root, ...edges.flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id])])];
1753
- const placeholders2 = ids.map(() => "?").join(",");
1754
- const rows = database.prepare(`
1755
- select a.id asset_id, a.project_id project, a.source, a.title, a.media_type, a.status, a.channel, a.campaign,
1756
- a.local_path, a.s3_key, a.checksum_sha256, coalesce(r.review_state, 'unreviewed') review_state,
1757
- r.notes review_notes, l.x layout_x, l.y layout_y
1758
- from assets a left join asset_reviews r on r.asset_id = a.id
1759
- left join asset_layouts l on l.project_id = a.project_id and l.root_asset_id = ? and l.asset_id = a.id
1760
- where a.project_id = ? and a.id in (${placeholders2})
1761
- `).all(root, project, ...ids);
1762
- const selected = selectedRows(database, project, root);
1763
- const childIds = new Set(edges.map((edge) => edge.parent_asset_id));
1764
- const selectedIds = new Set(selected.map((row) => row.asset_id));
1765
- const selections = selected.map((row) => ({
1766
- asset_id: row.asset_id,
1767
- notes: row.notes || void 0,
1768
- position: Number(row.position || 0),
1769
- selected_at: row.selected_at
1770
- }));
1771
- const selection = selections[0] || null;
1772
- const nodes = rows.map((row) => {
1773
- const position = typeof row.layout_x === "number" && typeof row.layout_y === "number" ? { x: row.layout_x, y: row.layout_y } : void 0;
1774
- const { layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
1775
- const nodeSelection = selections.find((item) => item.asset_id === row.asset_id);
1776
- return {
1777
- ...node,
1778
- is_latest: !childIds.has(row.asset_id),
1779
- position,
1780
- preview_url: canPreviewLocally(row.media_type, row.local_path) ? localPreviewUrl(project, row.local_path) : void 0,
1781
- selection_note: nodeSelection?.notes,
1782
- user_selected: selectedIds.has(row.asset_id)
1783
- };
1784
- });
1785
- database.close();
1786
- return {
1787
- project,
1788
- root_asset_id: root,
1789
- active_asset_id: assetId,
1790
- selected: selections.map((row) => row.asset_id),
1791
- selection,
1792
- selections,
1793
- latest: nodes.filter((node) => node.is_latest).map((node) => node.asset_id),
1794
- nodes,
1795
- edges,
1796
- fetchedAt: nowIso()
1797
- };
1873
+ try {
1874
+ expireActiveClaims(database);
1875
+ const claim = findClaimById(database, claimId, project);
1876
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1877
+ const events = database.prepare("select * from agent_claim_events where claim_id = ? order by created_at").all(claim.id);
1878
+ return { ok: true, claim, events: events.map(eventToRow) };
1879
+ } finally {
1880
+ database.close();
1881
+ }
1798
1882
  }
1799
- function updateLineageLayout(project, fields) {
1800
- if (fields.positions.length === 0) throw new LineageError("Lineage layout requires at least one position");
1883
+ function heartbeatAgentClaim(claimToken, ttlSeconds = defaultTtlSeconds) {
1801
1884
  const database = lineageDb();
1802
- requireAsset2(database, project, fields.rootAssetId);
1803
- for (const position of fields.positions) requireAsset2(database, project, position.assetId);
1804
- if (!fields.confirmWrite) {
1885
+ try {
1886
+ expireActiveClaims(database);
1887
+ const row = findClaimRowByToken(database, claimToken);
1888
+ if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
1889
+ const claim = rowToClaim(row);
1890
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1891
+ const timestamp = nowIso();
1892
+ database.prepare("update agent_claims set heartbeat_at = ?, expires_at = ? where id = ?").run(timestamp, expiresAtFrom(timestamp, ttlSeconds), claim.id);
1893
+ recordEvent(database, claim.id, "heartbeat", claim.agent_name, "Claim heartbeat received.");
1894
+ return { ok: true, claim: findClaimById(database, claim.id) };
1895
+ } finally {
1805
1896
  database.close();
1806
- return { ok: true, dryRun: true, root_asset_id: fields.rootAssetId, positions: fields.positions };
1807
- }
1808
- const timestamp = nowIso();
1809
- const statement = database.prepare(`
1810
- insert into asset_layouts (id, project_id, root_asset_id, asset_id, x, y, updated_at)
1811
- values (?, ?, ?, ?, ?, ?, ?)
1812
- on conflict(project_id, root_asset_id, asset_id) do update set
1813
- x = excluded.x, y = excluded.y, updated_at = excluded.updated_at
1814
- `);
1815
- for (const position of fields.positions) {
1816
- statement.run(`${project}:${fields.rootAssetId}:layout:${position.assetId}`, project, fields.rootAssetId, position.assetId, position.x, position.y, timestamp);
1817
1897
  }
1818
- database.close();
1819
- return { ok: true, message: `Saved ${fields.positions.length} lineage positions`, root_asset_id: fields.rootAssetId, positions: fields.positions };
1820
1898
  }
1821
- function getLineageNextAsset(project, rootAssetId) {
1899
+ function releaseAgentClaim(claimToken) {
1822
1900
  const database = lineageDb();
1823
- const root = resolveRoot(database, project, rootAssetId);
1824
- database.close();
1825
- const snapshot = getLineageSnapshot(project, root);
1826
- const selectedNodes = snapshot.selected.map((assetId) => snapshot.nodes.find((node) => node.asset_id === assetId)).filter((node) => Boolean(node));
1827
- const latestNodes = snapshot.nodes.filter((node) => snapshot.latest.includes(node.asset_id));
1828
- const warnings = [];
1829
- for (const selectedNode of selectedNodes) {
1830
- if (selectedNode.is_latest) continue;
1831
- warnings.push("Selected asset is not a latest leaf; agents should treat this as an intentional branch choice.");
1901
+ try {
1902
+ expireActiveClaims(database);
1903
+ const row = findClaimRowByToken(database, claimToken);
1904
+ if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
1905
+ const claim = rowToClaim(row);
1906
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1907
+ const timestamp = nowIso();
1908
+ database.prepare("update agent_claims set status = 'released', released_at = ? where id = ?").run(timestamp, claim.id);
1909
+ recordEvent(database, claim.id, "released", claim.agent_name, "Claim released by token holder.");
1910
+ return { ok: true, claim: findClaimById(database, claim.id) };
1911
+ } finally {
1912
+ database.close();
1832
1913
  }
1833
- if (selectedNodes.length > 0) {
1834
- return {
1835
- project,
1836
- root_asset_id: snapshot.root_asset_id,
1837
- strategy: "selected",
1838
- selection_mode: selectedNodes.length > 1 ? "multiple" : "single",
1839
- recommended_action: "evolve_variations",
1840
- reason: "user_selected",
1841
- next_asset: selectedNodes[0],
1842
- next_assets: selectedNodes,
1843
- latest: snapshot.latest,
1844
- selected: snapshot.selected,
1845
- selection: snapshot.selection,
1846
- selections: snapshot.selections,
1847
- candidates: latestNodes,
1848
- warnings,
1849
- fetchedAt: nowIso()
1850
- };
1914
+ }
1915
+ function releaseStaleAgentClaim(project, claimId, fields) {
1916
+ if (!fields.confirmWrite) throw new AgentClaimError("Releasing a stale agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1917
+ if (!fields.reason?.trim()) throw new AgentClaimError("Releasing a stale agent claim requires a reason.", 400, "reason_required");
1918
+ const database = lineageDb();
1919
+ try {
1920
+ expireActiveClaims(database);
1921
+ const claim = findClaimById(database, claimId, project);
1922
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1923
+ if (claim.status !== "active" || claim.derived_state !== "stale") {
1924
+ throw new AgentClaimError("Only stale active claims can be released without the claim token.", 409, "claim_not_stale", [claim]);
1925
+ }
1926
+ const timestamp = nowIso();
1927
+ database.prepare("update agent_claims set status = 'released', released_at = ?, revoked_by = ?, override_reason = ? where id = ?").run(timestamp, fields.actor || "human", fields.reason, claim.id);
1928
+ recordEvent(database, claim.id, "released", fields.actor || "human", fields.reason);
1929
+ return { ok: true, claim: findClaimById(database, claim.id) };
1930
+ } finally {
1931
+ database.close();
1851
1932
  }
1852
- if (latestNodes.length === 1) {
1853
- return {
1854
- project,
1855
- root_asset_id: snapshot.root_asset_id,
1856
- strategy: "single_latest",
1857
- selection_mode: "fallback",
1858
- recommended_action: "evolve_variations",
1859
- reason: "single_latest_fallback",
1860
- next_asset: latestNodes[0],
1861
- next_assets: [latestNodes[0]],
1862
- latest: snapshot.latest,
1863
- selected: snapshot.selected,
1864
- selection: snapshot.selection,
1865
- selections: snapshot.selections,
1866
- candidates: latestNodes,
1867
- warnings,
1868
- fetchedAt: nowIso()
1869
- };
1870
- }
1871
- return {
1872
- project,
1873
- root_asset_id: snapshot.root_asset_id,
1874
- strategy: latestNodes.length > 1 ? "ambiguous_latest" : "empty",
1875
- selection_mode: "none",
1876
- recommended_action: latestNodes.length > 1 ? "choose_next_base" : "none",
1877
- reason: latestNodes.length > 1 ? "multiple_latest_no_selection" : "no_lineage_candidates",
1878
- next_asset: null,
1879
- next_assets: [],
1880
- latest: snapshot.latest,
1881
- selected: snapshot.selected,
1882
- selection: snapshot.selection,
1883
- selections: snapshot.selections,
1884
- candidates: latestNodes,
1885
- warnings,
1886
- fetchedAt: nowIso()
1887
- };
1888
1933
  }
1889
- function getLineageChildren(project, parentAssetId) {
1890
- const snapshot = getLineageSnapshot(project, parentAssetId);
1891
- const edges = snapshot.edges.filter((edge) => edge.parent_asset_id === parentAssetId);
1892
- const childIds = new Set(edges.map((edge) => edge.child_asset_id));
1893
- return {
1894
- project,
1895
- parent_asset_id: parentAssetId,
1896
- children: snapshot.nodes.filter((node) => childIds.has(node.asset_id)),
1897
- edges,
1898
- fetchedAt: nowIso()
1899
- };
1934
+ function revokeAgentClaim(project, claimId, fields) {
1935
+ if (!fields.confirmWrite) throw new AgentClaimError("Revoking an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1936
+ if (!fields.reason?.trim()) throw new AgentClaimError("Revoking an agent claim requires a reason.", 400, "reason_required");
1937
+ const database = lineageDb();
1938
+ try {
1939
+ expireActiveClaims(database);
1940
+ const claim = findClaimById(database, claimId, project);
1941
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1942
+ const timestamp = nowIso();
1943
+ database.prepare(`
1944
+ update agent_claims set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
1945
+ where id = ?
1946
+ `).run(timestamp, fields.actor || "human", fields.reason, claim.id);
1947
+ recordEvent(database, claim.id, "revoked", fields.actor || "human", fields.reason);
1948
+ return { ok: true, claim: findClaimById(database, claim.id) };
1949
+ } finally {
1950
+ database.close();
1951
+ }
1900
1952
  }
1901
- function updateSelectedAsset(project, fields) {
1953
+ function transferAgentClaim(project, claimId, fields) {
1954
+ if (!fields.confirmWrite) throw new AgentClaimError("Transferring an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1955
+ const toAgentName = fields.toAgentName.trim();
1956
+ if (!toAgentName) throw new AgentClaimError("Transfer requires toAgentName.", 400, "agent_name_required");
1902
1957
  const database = lineageDb();
1903
- const inputAssetIds = normalizeSelectionInput(fields);
1904
- const root = fields.rootAssetId || (inputAssetIds[0] ? rootFor(database, project, inputAssetIds[0]) : "");
1905
- if (!root) throw new LineageError("Selection requires rootAssetId or assetId");
1906
- requireAsset2(database, project, root);
1907
- for (const assetId of inputAssetIds) requireAsset2(database, project, assetId);
1908
- const mode = fields.mode || "replace";
1909
- const limit = fields.maxSelections || LINEAGE_NEXT_VARIATION_LIMIT;
1910
- if (!fields.confirmWrite) {
1958
+ try {
1959
+ expireActiveClaims(database);
1960
+ const claim = findClaimById(database, claimId, project);
1961
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1962
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1963
+ database.prepare("update agent_claims set agent_name = ? where id = ?").run(toAgentName, claim.id);
1964
+ recordEvent(database, claim.id, "transferred", fields.actor || "human", fields.reason || `Transferred claim to ${toAgentName}.`, { to_agent_name: toAgentName });
1965
+ return { ok: true, claim: findClaimById(database, claim.id) };
1966
+ } finally {
1911
1967
  database.close();
1912
- return { ok: true, dryRun: true, root_asset_id: root, asset_ids: inputAssetIds, mode, clear: Boolean(fields.clear), max_selections: limit };
1913
1968
  }
1914
- const current = selectedRows(database, project, root);
1915
- let nextIds = current.map((row) => row.asset_id);
1916
- if (fields.clear) {
1917
- nextIds = [];
1918
- } else if (mode === "replace") {
1919
- nextIds = inputAssetIds;
1920
- } else if (mode === "add") {
1921
- nextIds = [...nextIds, ...inputAssetIds];
1922
- } else if (mode === "remove") {
1923
- nextIds = nextIds.filter((assetId) => !inputAssetIds.includes(assetId));
1924
- } else if (mode === "toggle") {
1925
- for (const assetId of inputAssetIds) {
1926
- nextIds = nextIds.includes(assetId) ? nextIds.filter((id) => id !== assetId) : [...nextIds, assetId];
1969
+ }
1970
+ function validateAgentClaimForWrite(fields) {
1971
+ if (fields.dangerLevel === "danger" && !fields.confirmWrite) {
1972
+ return denied("human_confirmation_required", "Dangerous write requires explicit human confirmation.");
1973
+ }
1974
+ if (!fields.claimToken) return denied("claim_required", "Mutating agent write requires a matching claim token.");
1975
+ const database = lineageDb();
1976
+ try {
1977
+ expireActiveClaims(database);
1978
+ const row = findClaimRowByToken(database, fields.claimToken);
1979
+ if (!row) return denied("claim_token_invalid", "Unknown or invalid claim token.");
1980
+ const claim = rowToClaim(row);
1981
+ if (claim.status !== "active") return denied("claim_not_active", `Agent claim is ${claim.status}.`);
1982
+ if (new Date(claim.expires_at).getTime() <= Date.now()) return denied("claim_expired", "Agent claim has expired.");
1983
+ if (claim.project !== fields.project) return denied("claim_project_mismatch", `Claim project ${claim.project} does not match ${fields.project}.`, [claim]);
1984
+ if (fields.channel && claim.channel && claim.channel !== fields.channel) {
1985
+ return denied("claim_channel_mismatch", `Claim channel ${claim.channel} does not match ${fields.channel}.`, [claim]);
1986
+ }
1987
+ if (!scopeAllowsWrite(claim, fields.scopeType, fields.targetId, fields.writeKind)) {
1988
+ return denied("claim_scope_mismatch", `Claim does not cover ${fields.scopeType} ${fields.targetId}.`, [claim]);
1927
1989
  }
1990
+ recordEvent(database, claim.id, "write_allowed", claim.agent_name, `${fields.writeKind} allowed.`, {
1991
+ danger_level: fields.dangerLevel,
1992
+ target_id: fields.targetId,
1993
+ write_kind: fields.writeKind
1994
+ });
1995
+ return { ok: true, claim, warnings: [] };
1996
+ } finally {
1997
+ database.close();
1928
1998
  }
1929
- nextIds = [...new Set(nextIds)];
1930
- if (!fields.clear && inputAssetIds.length === 0) throw new LineageError("Selection set requires assetId or assetIds");
1931
- if (nextIds.length > limit) throw new LineageError(`Select at most ${limit} assets for next variation`);
1932
- database.prepare("delete from asset_selections where project_id = ? and root_asset_id = ?").run(project, root);
1933
- const timestamp = nowIso();
1934
- const insert = database.prepare(`
1935
- insert into asset_selections (id, project_id, root_asset_id, asset_id, position, notes, selected_at)
1936
- values (?, ?, ?, ?, ?, ?, ?)
1937
- `);
1938
- nextIds.forEach((assetId, position) => {
1939
- const existing = current.find((row) => row.asset_id === assetId);
1940
- const notes = inputAssetIds.includes(assetId) ? fields.notes || existing?.notes : existing?.notes;
1941
- insert.run(selectionId(project, root, assetId), project, root, assetId, position, notes || null, timestamp);
1999
+ }
2000
+
2001
+ // src/server/lineageClaimGuards.ts
2002
+ function channelOverlaps2(left, right) {
2003
+ return !left || !right || left === right;
2004
+ }
2005
+ function hasActiveLineageWorkspaceClaim(project, targetId, channel) {
2006
+ return listAgentClaims(project).claims.some((claim) => {
2007
+ if (claim.project !== project || claim.status !== "active" || claim.derived_state === "expired") return false;
2008
+ if (claim.scope_type === "lineage_workspace") return claim.target_id === targetId;
2009
+ return claim.scope_type === "project_channel" && channelOverlaps2(channel, claim.channel);
1942
2010
  });
1943
- database.close();
1944
- const message = nextIds.length === 0 ? `Cleared selected assets for ${root}` : `Selected ${nextIds.length} asset${nextIds.length === 1 ? "" : "s"} for ${root}`;
1945
- return { ok: true, message, root_asset_id: root, asset_id: nextIds[0] || null, asset_ids: nextIds, mode };
1946
2011
  }
1947
- function updateAssetReview(project, fields) {
1948
- const allowed = /* @__PURE__ */ new Set(["unreviewed", "approved", "needs_revision", "rejected", "ignored"]);
1949
- if (!allowed.has(fields.reviewState)) throw new LineageError(`Unsupported review state: ${fields.reviewState}`);
1950
- const database = lineageDb();
1951
- requireAsset2(database, project, fields.assetId);
1952
- if (!fields.confirmWrite) {
1953
- database.close();
1954
- return { ok: true, dryRun: true, asset_id: fields.assetId, review_state: fields.reviewState, notes: fields.notes };
2012
+ function requireLineageWorkspaceClaimForWrite(fields) {
2013
+ if (!fields.confirmWrite) return;
2014
+ const targetId = lineageWorkspaceId(fields.project, fields.rootAssetId);
2015
+ if (!fields.claimToken && !hasActiveLineageWorkspaceClaim(fields.project, targetId, fields.channel)) return;
2016
+ const validation = validateAgentClaimForWrite({
2017
+ channel: fields.channel,
2018
+ claimToken: fields.claimToken,
2019
+ confirmWrite: fields.confirmWrite,
2020
+ dangerLevel: "enforce",
2021
+ project: fields.project,
2022
+ scopeType: "lineage_workspace",
2023
+ targetId,
2024
+ writeKind: fields.writeKind
2025
+ });
2026
+ if (!validation.ok) {
2027
+ const status = validation.code === "claim_required" || validation.code === "claim_token_invalid" ? 401 : 409;
2028
+ throw new AgentClaimError(validation.message, status, validation.code, validation.conflicts);
1955
2029
  }
1956
- const timestamp = nowIso();
1957
- database.prepare(`
1958
- insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
1959
- values (?, ?, ?, ?, ?, ?)
1960
- on conflict(asset_id) do update set
1961
- review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
1962
- ignored_at = excluded.ignored_at, notes = excluded.notes, updated_at = excluded.updated_at
1963
- `).run(fields.assetId, fields.reviewState, timestamp, fields.reviewState === "ignored" ? timestamp : null, fields.notes || null, timestamp);
1964
- database.close();
1965
- return { ok: true, message: `Marked ${fields.assetId} ${fields.reviewState}`, asset_id: fields.assetId, review_state: fields.reviewState };
1966
2030
  }
1967
2031
 
1968
- // src/server/agentClaims.ts
1969
- import { createHash as createHash2, randomBytes, timingSafeEqual } from "node:crypto";
1970
- var defaultTtlSeconds = 20 * 60;
1971
- var idleAfterSeconds = 5 * 60;
1972
- var staleAfterSeconds = 15 * 60;
1973
- var scopes = /* @__PURE__ */ new Set(["lineage_workspace", "content_post", "content_queue_lane", "selection_set", "project_channel"]);
1974
- var AgentClaimError = class extends Error {
1975
- constructor(message, status = 400, code = "agent_claim_error", conflicts = []) {
2032
+ // src/server/assetLineage.ts
2033
+ var LineageError = class extends Error {
2034
+ constructor(message, status = 400) {
1976
2035
  super(message);
1977
2036
  this.status = status;
1978
- this.code = code;
1979
- this.conflicts = conflicts;
1980
2037
  }
1981
2038
  status;
1982
- code;
1983
- conflicts;
1984
2039
  };
1985
- function isAgentClaimError(error) {
1986
- return error instanceof AgentClaimError;
2040
+ function isLineageError(error) {
2041
+ return error instanceof LineageError;
1987
2042
  }
1988
- function parseClaimTtl(value) {
1989
- if (!value) return defaultTtlSeconds;
1990
- const match = value.trim().match(/^(\d+)(s|m|h)?$/);
1991
- if (!match) throw new AgentClaimError(`Invalid claim ttl: ${value}`);
1992
- const amount = Number(match[1]);
1993
- const unit = match[2] || "s";
1994
- const multiplier = unit === "h" ? 3600 : unit === "m" ? 60 : 1;
1995
- const seconds = amount * multiplier;
1996
- if (!Number.isInteger(seconds) || seconds < 30 || seconds > 24 * 60 * 60) {
1997
- throw new AgentClaimError("Claim ttl must be between 30 seconds and 24 hours");
2043
+ function collectAssets(project, source) {
2044
+ const first = listAssets(project, { source, page: 1, pageSize: 100 });
2045
+ const assets = [...first.assets];
2046
+ for (let page = 2; page <= first.pagination.totalPages; page += 1) {
2047
+ assets.push(...listAssets(project, { source, page, pageSize: 100 }).assets);
1998
2048
  }
1999
- return seconds;
2049
+ return assets;
2000
2050
  }
2001
- function randomId(prefix) {
2002
- return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString("base64url").toLowerCase()}`;
2051
+ function upsertProject(database, project) {
2052
+ const timestamp = nowIso();
2053
+ database.prepare(`
2054
+ insert into projects (id, product, catalog_path, created_at, updated_at)
2055
+ values (?, ?, ?, ?, ?)
2056
+ on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
2057
+ `).run(project, project, join5(repoRoot, project, "assets", "catalog.json"), timestamp, timestamp);
2003
2058
  }
2004
- function tokenHash(token) {
2005
- return createHash2("sha256").update(token).digest("hex");
2059
+ function upsertAsset(database, project, asset) {
2060
+ const timestamp = nowIso();
2061
+ const source = asset.source === "local" ? "local" : "catalog";
2062
+ database.prepare(`
2063
+ insert into assets (
2064
+ id, project_id, source, local_path, s3_key, checksum_sha256, media_type, title, status,
2065
+ channel, campaign, audience, size_bytes, content_type, created_at, updated_at, last_seen_at
2066
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2067
+ on conflict(id) do update set
2068
+ source = excluded.source, local_path = excluded.local_path, s3_key = excluded.s3_key,
2069
+ checksum_sha256 = excluded.checksum_sha256, media_type = excluded.media_type,
2070
+ title = excluded.title, status = excluded.status, channel = excluded.channel,
2071
+ campaign = excluded.campaign, audience = excluded.audience, size_bytes = excluded.size_bytes,
2072
+ content_type = excluded.content_type, updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
2073
+ `).run(
2074
+ asset.asset_id,
2075
+ project,
2076
+ source,
2077
+ asset.local?.relative_path || null,
2078
+ asset.s3?.key || null,
2079
+ asset.local?.checksum_sha256 || asset.s3?.checksum_sha256 || null,
2080
+ asset.content_type,
2081
+ asset.title,
2082
+ asset.status,
2083
+ asset.channel || null,
2084
+ asset.campaign || null,
2085
+ asset.audience || null,
2086
+ asset.local?.size_bytes || asset.s3?.size_bytes || null,
2087
+ asset.local?.content_type || asset.s3?.content_type || null,
2088
+ timestamp,
2089
+ timestamp,
2090
+ timestamp
2091
+ );
2092
+ database.prepare(`
2093
+ insert into asset_reviews (asset_id, review_state, updated_at)
2094
+ values (?, 'unreviewed', ?)
2095
+ on conflict(asset_id) do nothing
2096
+ `).run(asset.asset_id, timestamp);
2006
2097
  }
2007
- function safeEqual(a, b) {
2008
- const left = Buffer.from(a);
2009
- const right = Buffer.from(b);
2010
- return left.length === right.length && timingSafeEqual(left, right);
2098
+ function indexLineageAssets(project = defaultProject) {
2099
+ const database = lineageDb();
2100
+ const catalog = collectAssets(project, "catalog");
2101
+ const local = collectAssets(project, "local");
2102
+ upsertProject(database, project);
2103
+ for (const asset of [...catalog, ...local]) upsertAsset(database, project, asset);
2104
+ database.close();
2105
+ return { catalog: catalog.length, local: local.length, total: catalog.length + local.length, database: lineageDbPath() };
2011
2106
  }
2012
- function expiresAtFrom(timestamp, ttlSeconds) {
2013
- return new Date(new Date(timestamp).getTime() + ttlSeconds * 1e3).toISOString();
2107
+ function requireAsset2(database, project, assetId) {
2108
+ const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
2109
+ if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
2014
2110
  }
2015
- function metadataJson(metadata2) {
2016
- return metadata2 ? JSON.stringify(metadata2) : null;
2111
+ function parentOf(database, project, assetId) {
2112
+ const row = database.prepare("select parent_asset_id from asset_edges where project_id = ? and child_asset_id = ? order by created_at limit 1").get(project, assetId);
2113
+ return row?.parent_asset_id;
2017
2114
  }
2018
- function parseMetadata(value) {
2019
- if (typeof value !== "string" || !value) return void 0;
2115
+ function rootFor(database, project, assetId) {
2116
+ let root = assetId;
2117
+ const seen = /* @__PURE__ */ new Set();
2118
+ while (!seen.has(root)) {
2119
+ seen.add(root);
2120
+ const parent = parentOf(database, project, root);
2121
+ if (!parent) return root;
2122
+ root = parent;
2123
+ }
2124
+ return assetId;
2125
+ }
2126
+ function assertCanonicalRoot(database, project, root) {
2127
+ requireAsset2(database, project, root);
2128
+ const canonicalRoot = rootFor(database, project, root);
2129
+ if (canonicalRoot !== root) throw new LineageError(`Asset ${root} is not a lineage root`, 400);
2130
+ }
2131
+ function explicitWorkspaceRoot(database, project, assetId) {
2132
+ const row = database.prepare(`
2133
+ select root_asset_id from lineage_workspaces
2134
+ where project_id = ? and root_asset_id = ? and status != 'archived'
2135
+ `).get(project, assetId);
2136
+ return row?.root_asset_id;
2137
+ }
2138
+ function nearestWorkspaceRoot(database, project, assetId) {
2139
+ let current = assetId;
2140
+ const seen = /* @__PURE__ */ new Set();
2141
+ while (!seen.has(current)) {
2142
+ seen.add(current);
2143
+ const explicit = explicitWorkspaceRoot(database, project, current);
2144
+ if (explicit) return explicit;
2145
+ const parent = parentOf(database, project, current);
2146
+ if (!parent) return current;
2147
+ current = parent;
2148
+ }
2149
+ return assetId;
2150
+ }
2151
+ function assetChannel(database, project, assetId) {
2152
+ const row = database.prepare("select channel from assets where project_id = ? and id = ?").get(project, assetId);
2153
+ return row?.channel;
2154
+ }
2155
+ function lineageWriteClaimContext(database, project, assetId) {
2156
+ return {
2157
+ channel: assetChannel(database, project, assetId),
2158
+ rootAssetId: nearestWorkspaceRoot(database, project, assetId)
2159
+ };
2160
+ }
2161
+ function getLineageWriteClaimContext(project, assetId) {
2162
+ const database = lineageDb();
2020
2163
  try {
2021
- const parsed = JSON.parse(value);
2022
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
2023
- } catch {
2024
- return void 0;
2164
+ requireAsset2(database, project, assetId);
2165
+ return lineageWriteClaimContext(database, project, assetId);
2166
+ } finally {
2167
+ database.close();
2025
2168
  }
2026
2169
  }
2027
- function derivedState(row, now = /* @__PURE__ */ new Date()) {
2028
- if (row.status !== "active") return row.status === "expired" ? "expired" : "stale";
2029
- if (new Date(row.expires_at).getTime() <= now.getTime()) return "expired";
2030
- const ageSeconds = Math.max(0, Math.floor((now.getTime() - new Date(row.heartbeat_at).getTime()) / 1e3));
2031
- if (ageSeconds >= staleAfterSeconds) return "stale";
2032
- if (ageSeconds >= idleAfterSeconds) return "idle";
2033
- return "active";
2170
+ function latestSelectedRoot(database, project) {
2171
+ const row = database.prepare("select root_asset_id from asset_selections where project_id = ? order by selected_at desc limit 1").get(project);
2172
+ return row?.root_asset_id;
2034
2173
  }
2035
- function rowToClaim(row, now = /* @__PURE__ */ new Date()) {
2036
- const heartbeatAt = String(row.heartbeat_at);
2174
+ function resolveRoot(database, project, rootAssetId) {
2175
+ if (rootAssetId) {
2176
+ requireAsset2(database, project, rootAssetId);
2177
+ return rootAssetId;
2178
+ }
2179
+ const root = activeLineageWorkspaceRoot(project) || latestSelectedRoot(database, project);
2180
+ if (!root) throw new LineageError("Lineage command requires --root unless a project selection exists");
2181
+ requireAsset2(database, project, root);
2182
+ return root;
2183
+ }
2184
+ function edgeId(project, parent, child) {
2185
+ return `${project}:${parent}:derived_from:${child}`;
2186
+ }
2187
+ function rerollRequestId(project, root, node, timestamp) {
2188
+ return `${project}:${root}:reroll:${node}:${Date.parse(timestamp) || Date.now()}`;
2189
+ }
2190
+ function rowString(value) {
2191
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2192
+ }
2193
+ function rerollRequestFrom(row) {
2037
2194
  return {
2038
2195
  id: String(row.id),
2039
- project: String(row.project_id),
2040
- channel: typeof row.channel === "string" ? row.channel : void 0,
2041
- scope_type: String(row.scope_type),
2042
- target_id: String(row.target_id),
2043
- target_title: typeof row.target_title === "string" ? row.target_title : void 0,
2044
- agent_id: typeof row.agent_id === "string" ? row.agent_id : void 0,
2045
- agent_name: String(row.agent_name),
2046
- agent_kind: String(row.agent_kind),
2047
- thread_id: typeof row.thread_id === "string" ? row.thread_id : void 0,
2048
- status: String(row.status),
2196
+ project_id: String(row.project_id),
2197
+ root_asset_id: String(row.root_asset_id),
2198
+ node_asset_id: String(row.node_asset_id),
2199
+ status: row.status,
2200
+ requested_by: row.requested_by,
2201
+ notes: rowString(row.notes),
2049
2202
  created_at: String(row.created_at),
2050
- heartbeat_at: heartbeatAt,
2051
- expires_at: String(row.expires_at),
2052
- released_at: typeof row.released_at === "string" ? row.released_at : void 0,
2053
- revoked_at: typeof row.revoked_at === "string" ? row.revoked_at : void 0,
2054
- revoked_by: typeof row.revoked_by === "string" ? row.revoked_by : void 0,
2055
- override_reason: typeof row.override_reason === "string" ? row.override_reason : void 0,
2056
- metadata: parseMetadata(row.metadata_json),
2057
- heartbeat_age_seconds: Math.max(0, Math.floor((now.getTime() - new Date(heartbeatAt).getTime()) / 1e3)),
2058
- derived_state: derivedState({
2059
- expires_at: String(row.expires_at),
2060
- heartbeat_at: heartbeatAt,
2061
- status: String(row.status)
2062
- }, now)
2203
+ resolved_at: rowString(row.resolved_at)
2063
2204
  };
2064
2205
  }
2065
- function eventToRow(row) {
2206
+ function attemptFrom(row) {
2066
2207
  return {
2067
- claim_id: String(row.claim_id),
2068
- event_type: String(row.event_type),
2069
- actor: typeof row.actor === "string" ? row.actor : void 0,
2070
- message: typeof row.message === "string" ? row.message : void 0,
2208
+ id: String(row.id),
2209
+ project_id: String(row.project_id),
2210
+ node_asset_id: String(row.node_asset_id),
2211
+ asset_id: String(row.asset_id),
2212
+ attempt_index: Number(row.attempt_index),
2213
+ source: row.source,
2214
+ prompt: rowString(row.prompt),
2215
+ generation_job_id: rowString(row.generation_job_id),
2216
+ file_path: rowString(row.file_path),
2217
+ checksum_sha256: rowString(row.checksum_sha256),
2071
2218
  created_at: String(row.created_at),
2072
- metadata: parseMetadata(row.metadata_json)
2219
+ promoted_at: rowString(row.promoted_at),
2220
+ is_current: Boolean(Number(row.is_current))
2073
2221
  };
2074
2222
  }
2075
- function ensureProject3(database, project) {
2076
- const timestamp = nowIso();
2077
- database.prepare(`
2078
- insert into projects (id, product, created_at, updated_at)
2079
- values (?, ?, ?, ?)
2080
- on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
2081
- `).run(project, project, timestamp, timestamp);
2082
- }
2083
- function recordEvent(database, claimId, eventType, actor, message, metadata2) {
2084
- database.prepare(`
2085
- insert into agent_claim_events (id, claim_id, event_type, actor, message, created_at, metadata_json)
2086
- values (?, ?, ?, ?, ?, ?, ?)
2087
- `).run(randomId("claim_event"), claimId, eventType, actor || null, message || null, nowIso(), metadataJson(metadata2));
2088
- }
2089
- function expireActiveClaims(database) {
2090
- const timestamp = nowIso();
2091
- const expired = database.prepare(`
2092
- select id from agent_claims where status = 'active' and expires_at <= ?
2093
- `).all(timestamp);
2094
- if (expired.length === 0) return;
2095
- database.prepare(`
2096
- update agent_claims set status = 'expired' where status = 'active' and expires_at <= ?
2097
- `).run(timestamp);
2098
- for (const claim of expired) recordEvent(database, claim.id, "expired", "system", "Claim expired after missed heartbeat.");
2099
- }
2100
- function normalizeScope(scopeType) {
2101
- if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
2102
- return scopeType;
2223
+ function implicitAttempt(row, isCurrent = true) {
2224
+ const createdAt = row.asset_created_at || nowIso();
2225
+ return {
2226
+ id: `${row.project}:${row.asset_id}:attempt:implicit`,
2227
+ project_id: row.project,
2228
+ node_asset_id: row.asset_id,
2229
+ asset_id: row.asset_id,
2230
+ attempt_index: 1,
2231
+ source: "initial",
2232
+ file_path: row.local_path,
2233
+ checksum_sha256: row.checksum_sha256,
2234
+ created_at: createdAt,
2235
+ promoted_at: createdAt,
2236
+ is_current: isCurrent
2237
+ };
2103
2238
  }
2104
- function channelOverlaps(left, right) {
2105
- return !left || !right || left === right;
2239
+ function withImplicitAttempt(physicalAttempts, row) {
2240
+ if (physicalAttempts.some((attempt) => attempt.source === "initial")) return physicalAttempts;
2241
+ return [...physicalAttempts, implicitAttempt(row, !physicalAttempts.some((attempt) => attempt.is_current))];
2106
2242
  }
2107
- function claimOverlaps(candidate, existing) {
2108
- if (candidate.project !== existing.project) return false;
2109
- if (!channelOverlaps(candidate.channel, existing.channel)) return false;
2110
- if (candidate.scope_type === existing.scope_type && candidate.target_id === existing.target_id) return true;
2111
- return candidate.scope_type === "project_channel" || existing.scope_type === "project_channel";
2243
+ function assertNodeInRoot(database, project, root, node) {
2244
+ assertCanonicalRoot(database, project, root);
2245
+ requireAsset2(database, project, node);
2246
+ const nodeRoot = rootFor(database, project, node);
2247
+ if (nodeRoot !== root) throw new LineageError(`Asset ${node} is not in lineage rooted at ${root}`);
2112
2248
  }
2113
- function activeClaims(database, project) {
2114
- const rows = project ? database.prepare("select * from agent_claims where project_id = ? and status = 'active' order by heartbeat_at desc").all(project) : database.prepare("select * from agent_claims where status = 'active' order by project_id, heartbeat_at desc").all();
2115
- return rows.map((row) => rowToClaim(row));
2249
+ function canPreviewLocally(mediaType, localPath) {
2250
+ return Boolean(localPath && ["image", "video", "gif"].includes(mediaType));
2116
2251
  }
2117
- function findClaimById(database, claimId, project) {
2118
- const row = project ? database.prepare("select * from agent_claims where project_id = ? and id = ?").get(project, claimId) : database.prepare("select * from agent_claims where id = ?").get(claimId);
2119
- return row ? rowToClaim(row) : null;
2252
+ function localPreviewUrl(project, localPath) {
2253
+ if (!localPath) return void 0;
2254
+ const params = new URLSearchParams({ project, path: localPath });
2255
+ return `/api/assets/local-preview?${params.toString()}`;
2120
2256
  }
2121
- function findClaimRowByToken(database, claimToken) {
2122
- const claimId = claimToken.split(".")[0];
2123
- const row = database.prepare("select * from agent_claims where id = ?").get(claimId);
2124
- if (!row) return null;
2125
- return safeEqual(String(row.token_hash), tokenHash(claimToken)) ? row : null;
2257
+ function linkLineageAssets(project, fields) {
2258
+ const database = lineageDb();
2259
+ requireAsset2(database, project, fields.parentAssetId);
2260
+ requireAsset2(database, project, fields.childAssetId);
2261
+ if (fields.parentAssetId === fields.childAssetId) throw new LineageError("Lineage link cannot point to itself");
2262
+ const claimContext = lineageWriteClaimContext(database, project, fields.parentAssetId);
2263
+ try {
2264
+ requireLineageWorkspaceClaimForWrite({
2265
+ channel: claimContext.channel,
2266
+ claimToken: fields.claimToken,
2267
+ confirmWrite: fields.confirmWrite,
2268
+ project,
2269
+ rootAssetId: claimContext.rootAssetId,
2270
+ writeKind: "lineage_link"
2271
+ });
2272
+ } catch (error) {
2273
+ database.close();
2274
+ throw error;
2275
+ }
2276
+ const edge = {
2277
+ id: edgeId(project, fields.parentAssetId, fields.childAssetId),
2278
+ parent_asset_id: fields.parentAssetId,
2279
+ child_asset_id: fields.childAssetId,
2280
+ relation_type: "derived_from",
2281
+ created_at: nowIso()
2282
+ };
2283
+ if (!fields.confirmWrite) {
2284
+ database.close();
2285
+ return { ok: true, dryRun: true, edge };
2286
+ }
2287
+ database.prepare(`
2288
+ insert into asset_edges (id, project_id, parent_asset_id, child_asset_id, relation_type, created_at)
2289
+ values (?, ?, ?, ?, 'derived_from', ?)
2290
+ on conflict(project_id, parent_asset_id, child_asset_id, relation_type) do nothing
2291
+ `).run(edge.id, project, edge.parent_asset_id, edge.child_asset_id, edge.created_at);
2292
+ database.close();
2293
+ return { ok: true, message: `Linked ${edge.child_asset_id} from ${edge.parent_asset_id}`, edge };
2126
2294
  }
2127
- function denied(code, message, conflicts = []) {
2128
- return { ok: false, code, message, conflicts };
2295
+ function descendants(database, project, root) {
2296
+ const edges = [];
2297
+ const queue = [root];
2298
+ const seen = /* @__PURE__ */ new Set();
2299
+ while (queue.length > 0) {
2300
+ const parent = queue.shift();
2301
+ if (seen.has(parent)) continue;
2302
+ seen.add(parent);
2303
+ const rows = database.prepare("select id, parent_asset_id, child_asset_id, relation_type, created_at from asset_edges where project_id = ? and parent_asset_id = ? order by created_at").all(project, parent);
2304
+ edges.push(...rows);
2305
+ queue.push(...rows.map((row) => row.child_asset_id));
2306
+ }
2307
+ return edges;
2129
2308
  }
2130
- function scopeAllowsWrite(claim, scopeType, targetId, writeKind) {
2131
- if (claim.scope_type === scopeType && claim.target_id === targetId) return true;
2132
- if (claim.scope_type === "project_channel") return true;
2133
- if (claim.scope_type === "lineage_workspace" && scopeType === "lineage_workspace") return claim.target_id === targetId;
2134
- if (claim.scope_type === "content_queue_lane" && writeKind === "content_queue_next") return true;
2135
- return false;
2309
+ function getLineageSnapshot(project, assetId) {
2310
+ const database = lineageDb();
2311
+ requireAsset2(database, project, assetId);
2312
+ const root = explicitWorkspaceRoot(database, project, assetId) || rootFor(database, project, assetId);
2313
+ const edges = descendants(database, project, root);
2314
+ const ids = [.../* @__PURE__ */ new Set([root, ...edges.flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id])])];
2315
+ const placeholders2 = ids.map(() => "?").join(",");
2316
+ const rows = database.prepare(`
2317
+ select a.id asset_id, a.project_id project, a.source, a.title, a.media_type, a.status, a.channel, a.campaign,
2318
+ a.local_path, a.s3_key, a.checksum_sha256, coalesce(r.review_state, 'unreviewed') review_state,
2319
+ r.notes review_notes, a.created_at asset_created_at, l.x layout_x, l.y layout_y
2320
+ from assets a left join asset_reviews r on r.asset_id = a.id
2321
+ left join asset_layouts l on l.project_id = a.project_id and l.root_asset_id = ? and l.asset_id = a.id
2322
+ where a.project_id = ? and a.id in (${placeholders2})
2323
+ `).all(root, project, ...ids);
2324
+ const attemptRows = ids.length > 0 ? database.prepare(`select * from asset_attempts where project_id = ? and node_asset_id in (${placeholders2}) order by node_asset_id, attempt_index desc`).all(project, ...ids) : [];
2325
+ const attemptsByNode = /* @__PURE__ */ new Map();
2326
+ for (const attempt of attemptRows.map(attemptFrom)) {
2327
+ attemptsByNode.set(attempt.node_asset_id, [...attemptsByNode.get(attempt.node_asset_id) || [], attempt]);
2328
+ }
2329
+ 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 (${placeholders2}) order by created_at`).all(project, root, ...ids) : [];
2330
+ const rerollsByNode = new Map(rerollRows.map((row) => {
2331
+ const request = rerollRequestFrom(row);
2332
+ return [request.node_asset_id, request];
2333
+ }));
2334
+ const selected = selectedRows(database, project, root);
2335
+ const childIds = new Set(edges.map((edge) => edge.parent_asset_id));
2336
+ const selectedIds = new Set(selected.map((row) => row.asset_id));
2337
+ const selections = selected.map((row) => ({
2338
+ asset_id: row.asset_id,
2339
+ notes: row.notes || void 0,
2340
+ position: Number(row.position || 0),
2341
+ selected_at: row.selected_at
2342
+ }));
2343
+ const selection = selections[0] || null;
2344
+ const nodes = rows.map((row) => {
2345
+ const position = typeof row.layout_x === "number" && typeof row.layout_y === "number" ? { x: row.layout_x, y: row.layout_y } : void 0;
2346
+ const { asset_created_at: _assetCreatedAt, layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
2347
+ const nodeSelection = selections.find((item) => item.asset_id === row.asset_id);
2348
+ const attempts = withImplicitAttempt(attemptsByNode.get(row.asset_id) || [], row);
2349
+ const currentAttempt = attempts.find((attempt) => attempt.is_current) || attempts[0];
2350
+ const previewPath = currentAttempt?.file_path || row.local_path;
2351
+ return {
2352
+ ...node,
2353
+ attempt_count: attempts.length,
2354
+ current_attempt: currentAttempt,
2355
+ is_latest: !childIds.has(row.asset_id),
2356
+ position,
2357
+ preview_url: canPreviewLocally(row.media_type, previewPath) ? localPreviewUrl(project, previewPath) : void 0,
2358
+ reroll_request: rerollsByNode.get(row.asset_id),
2359
+ selection_note: nodeSelection?.notes,
2360
+ user_selected: selectedIds.has(row.asset_id)
2361
+ };
2362
+ });
2363
+ database.close();
2364
+ return {
2365
+ project,
2366
+ root_asset_id: root,
2367
+ active_asset_id: assetId,
2368
+ selected: selections.map((row) => row.asset_id),
2369
+ selection,
2370
+ selections,
2371
+ latest: nodes.filter((node) => node.is_latest).map((node) => node.asset_id),
2372
+ nodes,
2373
+ edges,
2374
+ fetchedAt: nowIso()
2375
+ };
2136
2376
  }
2137
- function createAgentClaim(fields) {
2138
- const project = fields.project.trim();
2139
- const targetId = fields.targetId.trim();
2140
- const agentName = fields.agentName.trim();
2141
- if (!project) throw new AgentClaimError("Agent claim requires project");
2142
- if (!targetId) throw new AgentClaimError("Agent claim requires target");
2143
- if (!agentName) throw new AgentClaimError("Agent claim requires agent name");
2144
- const scopeType = normalizeScope(fields.scopeType);
2145
- const ttlSeconds = fields.ttlSeconds || defaultTtlSeconds;
2377
+ function updateLineageLayout(project, fields) {
2378
+ if (fields.positions.length === 0) throw new LineageError("Lineage layout requires at least one position");
2146
2379
  const database = lineageDb();
2380
+ requireAsset2(database, project, fields.rootAssetId);
2381
+ for (const position of fields.positions) requireAsset2(database, project, position.assetId);
2147
2382
  try {
2148
- ensureProject3(database, project);
2149
- expireActiveClaims(database);
2150
- const candidate = { project, channel: fields.channel?.trim() || void 0, scope_type: scopeType, target_id: targetId };
2151
- const conflicts = activeClaims(database, project).filter((claim2) => claimOverlaps(candidate, claim2));
2152
- if (conflicts.length > 0 && !fields.force) {
2153
- throw new AgentClaimError("Target already has an active overlapping agent claim.", 409, "target_already_claimed", conflicts);
2154
- }
2155
- if (conflicts.length > 0 && !fields.reason?.trim()) {
2156
- throw new AgentClaimError("Overriding an active claim requires --reason.", 400, "override_reason_required", conflicts);
2157
- }
2158
- const timestamp = nowIso();
2159
- for (const conflict of conflicts) {
2160
- database.prepare(`
2161
- update agent_claims
2162
- set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
2163
- where id = ? and status = 'active'
2164
- `).run(timestamp, agentName, fields.reason || null, conflict.id);
2165
- recordEvent(database, conflict.id, "revoked", agentName, fields.reason || "Revoked by forced claim takeover.");
2166
- recordEvent(database, conflict.id, "conflict", agentName, `Overridden by ${agentName}.`, { new_claim_target: targetId });
2167
- }
2168
- const id = randomId("claim");
2169
- const secret = randomBytes(24).toString("base64url");
2170
- const claimToken = `${id}.${secret}`;
2171
- const expiresAt = expiresAtFrom(timestamp, ttlSeconds);
2172
- database.prepare(`
2173
- insert into agent_claims (
2174
- id, token_hash, project_id, channel, scope_type, target_id, target_title, agent_id, agent_name,
2175
- agent_kind, thread_id, status, created_at, heartbeat_at, expires_at, metadata_json
2176
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
2177
- `).run(
2178
- id,
2179
- tokenHash(claimToken),
2383
+ requireLineageWorkspaceClaimForWrite({
2384
+ channel: assetChannel(database, project, fields.rootAssetId),
2385
+ claimToken: fields.claimToken,
2386
+ confirmWrite: fields.confirmWrite,
2180
2387
  project,
2181
- candidate.channel || null,
2182
- scopeType,
2183
- targetId,
2184
- fields.targetTitle?.trim() || null,
2185
- fields.agentId?.trim() || null,
2186
- agentName,
2187
- fields.agentKind?.trim() || "codex",
2188
- fields.threadId?.trim() || null,
2189
- timestamp,
2190
- timestamp,
2191
- expiresAt,
2192
- metadataJson(fields.metadata)
2193
- );
2194
- recordEvent(database, id, "created", agentName, `Claimed ${scopeType} ${targetId}.`, { ttl_seconds: ttlSeconds });
2195
- const claim = findClaimById(database, id);
2196
- return { ok: true, claim, claim_token: claimToken, conflicts_revoked: conflicts.map((conflict) => conflict.id) };
2197
- } finally {
2388
+ rootAssetId: fields.rootAssetId,
2389
+ writeKind: "lineage_layout"
2390
+ });
2391
+ } catch (error) {
2392
+ database.close();
2393
+ throw error;
2394
+ }
2395
+ if (!fields.confirmWrite) {
2198
2396
  database.close();
2397
+ return { ok: true, dryRun: true, root_asset_id: fields.rootAssetId, positions: fields.positions };
2398
+ }
2399
+ const timestamp = nowIso();
2400
+ const statement = database.prepare(`
2401
+ insert into asset_layouts (id, project_id, root_asset_id, asset_id, x, y, updated_at)
2402
+ values (?, ?, ?, ?, ?, ?, ?)
2403
+ on conflict(project_id, root_asset_id, asset_id) do update set
2404
+ x = excluded.x, y = excluded.y, updated_at = excluded.updated_at
2405
+ `);
2406
+ for (const position of fields.positions) {
2407
+ statement.run(`${project}:${fields.rootAssetId}:layout:${position.assetId}`, project, fields.rootAssetId, position.assetId, position.x, position.y, timestamp);
2408
+ }
2409
+ database.close();
2410
+ return { ok: true, message: `Saved ${fields.positions.length} lineage positions`, root_asset_id: fields.rootAssetId, positions: fields.positions };
2411
+ }
2412
+ function getLineageNextAsset(project, rootAssetId) {
2413
+ const database = lineageDb();
2414
+ const root = resolveRoot(database, project, rootAssetId);
2415
+ database.close();
2416
+ const snapshot = getLineageSnapshot(project, root);
2417
+ const selectedNodes = snapshot.selected.map((assetId) => snapshot.nodes.find((node) => node.asset_id === assetId)).filter((node) => Boolean(node));
2418
+ const latestNodes = snapshot.nodes.filter((node) => snapshot.latest.includes(node.asset_id));
2419
+ const warnings = [];
2420
+ for (const selectedNode of selectedNodes) {
2421
+ if (selectedNode.is_latest) continue;
2422
+ warnings.push("Selected asset is not a latest leaf; agents should treat this as an intentional branch choice.");
2423
+ }
2424
+ if (selectedNodes.length > 0) {
2425
+ return {
2426
+ project,
2427
+ root_asset_id: snapshot.root_asset_id,
2428
+ strategy: "selected",
2429
+ selection_mode: selectedNodes.length > 1 ? "multiple" : "single",
2430
+ recommended_action: "evolve_variations",
2431
+ reason: "user_selected",
2432
+ next_asset: selectedNodes[0],
2433
+ next_assets: selectedNodes,
2434
+ latest: snapshot.latest,
2435
+ selected: snapshot.selected,
2436
+ selection: snapshot.selection,
2437
+ selections: snapshot.selections,
2438
+ candidates: latestNodes,
2439
+ warnings,
2440
+ fetchedAt: nowIso()
2441
+ };
2442
+ }
2443
+ if (latestNodes.length === 1) {
2444
+ return {
2445
+ project,
2446
+ root_asset_id: snapshot.root_asset_id,
2447
+ strategy: "single_latest",
2448
+ selection_mode: "fallback",
2449
+ recommended_action: "evolve_variations",
2450
+ reason: "single_latest_fallback",
2451
+ next_asset: latestNodes[0],
2452
+ next_assets: [latestNodes[0]],
2453
+ latest: snapshot.latest,
2454
+ selected: snapshot.selected,
2455
+ selection: snapshot.selection,
2456
+ selections: snapshot.selections,
2457
+ candidates: latestNodes,
2458
+ warnings,
2459
+ fetchedAt: nowIso()
2460
+ };
2199
2461
  }
2462
+ return {
2463
+ project,
2464
+ root_asset_id: snapshot.root_asset_id,
2465
+ strategy: latestNodes.length > 1 ? "ambiguous_latest" : "empty",
2466
+ selection_mode: "none",
2467
+ recommended_action: latestNodes.length > 1 ? "choose_next_base" : "none",
2468
+ reason: latestNodes.length > 1 ? "multiple_latest_no_selection" : "no_lineage_candidates",
2469
+ next_asset: null,
2470
+ next_assets: [],
2471
+ latest: snapshot.latest,
2472
+ selected: snapshot.selected,
2473
+ selection: snapshot.selection,
2474
+ selections: snapshot.selections,
2475
+ candidates: latestNodes,
2476
+ warnings,
2477
+ fetchedAt: nowIso()
2478
+ };
2200
2479
  }
2201
- function listAgentClaims(project) {
2202
- const database = lineageDb();
2203
- try {
2204
- expireActiveClaims(database);
2205
- const rows = project ? database.prepare("select * from agent_claims where project_id = ? order by status, heartbeat_at desc").all(project) : database.prepare("select * from agent_claims order by project_id, status, heartbeat_at desc").all();
2206
- return { ok: true, claims: rows.map((row) => rowToClaim(row)), fetchedAt: nowIso() };
2207
- } finally {
2208
- database.close();
2209
- }
2480
+ function getLineageChildren(project, parentAssetId) {
2481
+ const snapshot = getLineageSnapshot(project, parentAssetId);
2482
+ const edges = snapshot.edges.filter((edge) => edge.parent_asset_id === parentAssetId);
2483
+ const childIds = new Set(edges.map((edge) => edge.child_asset_id));
2484
+ return {
2485
+ project,
2486
+ parent_asset_id: parentAssetId,
2487
+ children: snapshot.nodes.filter((node) => childIds.has(node.asset_id)),
2488
+ edges,
2489
+ fetchedAt: nowIso()
2490
+ };
2210
2491
  }
2211
- function inspectAgentClaim(claimId, project) {
2492
+ function listLineageRerollRequests(project, rootAssetId) {
2212
2493
  const database = lineageDb();
2213
2494
  try {
2214
- expireActiveClaims(database);
2215
- const claim = findClaimById(database, claimId, project);
2216
- if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
2217
- const events = database.prepare("select * from agent_claim_events where claim_id = ? order by created_at").all(claim.id);
2218
- return { ok: true, claim, events: events.map(eventToRow) };
2495
+ assertCanonicalRoot(database, project, rootAssetId);
2496
+ const rows = database.prepare(`
2497
+ select * from asset_reroll_requests
2498
+ where project_id = ? and root_asset_id = ? and status = 'pending'
2499
+ order by created_at, id
2500
+ `).all(project, rootAssetId);
2501
+ return { project, root_asset_id: rootAssetId, requests: rows.map(rerollRequestFrom), fetchedAt: nowIso() };
2219
2502
  } finally {
2220
2503
  database.close();
2221
2504
  }
2222
2505
  }
2223
- function heartbeatAgentClaim(claimToken, ttlSeconds = defaultTtlSeconds) {
2506
+ function getLineageAttempts(project, rootAssetId, nodeAssetId) {
2224
2507
  const database = lineageDb();
2225
2508
  try {
2226
- expireActiveClaims(database);
2227
- const row = findClaimRowByToken(database, claimToken);
2228
- if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
2229
- const claim = rowToClaim(row);
2230
- if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
2231
- const timestamp = nowIso();
2232
- database.prepare("update agent_claims set heartbeat_at = ?, expires_at = ? where id = ?").run(timestamp, expiresAtFrom(timestamp, ttlSeconds), claim.id);
2233
- recordEvent(database, claim.id, "heartbeat", claim.agent_name, "Claim heartbeat received.");
2234
- return { ok: true, claim: findClaimById(database, claim.id) };
2509
+ assertNodeInRoot(database, project, rootAssetId, nodeAssetId);
2510
+ const rows = database.prepare(`
2511
+ select * from asset_attempts
2512
+ where project_id = ? and node_asset_id = ?
2513
+ order by attempt_index desc, created_at desc
2514
+ `).all(project, nodeAssetId);
2515
+ const asset = database.prepare("select id asset_id, project_id project, local_path, checksum_sha256, created_at asset_created_at from assets where project_id = ? and id = ?").get(project, nodeAssetId);
2516
+ return { project, root_asset_id: rootAssetId, node_asset_id: nodeAssetId, attempts: withImplicitAttempt(rows.map(attemptFrom), asset), fetchedAt: nowIso() };
2235
2517
  } finally {
2236
2518
  database.close();
2237
2519
  }
2238
2520
  }
2239
- function releaseAgentClaim(claimToken) {
2521
+ function promoteLineageAttempt(project, fields) {
2240
2522
  const database = lineageDb();
2241
2523
  try {
2242
- expireActiveClaims(database);
2243
- const row = findClaimRowByToken(database, claimToken);
2244
- if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
2245
- const claim = rowToClaim(row);
2246
- if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
2524
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
2525
+ const asset = database.prepare("select id asset_id, project_id project, local_path, checksum_sha256, created_at asset_created_at from assets where project_id = ? and id = ?").get(project, fields.nodeAssetId);
2526
+ const rows = database.prepare(`
2527
+ select * from asset_attempts
2528
+ where project_id = ? and node_asset_id = ?
2529
+ order by attempt_index desc, created_at desc
2530
+ `).all(project, fields.nodeAssetId);
2531
+ const attempts = withImplicitAttempt(rows.map(attemptFrom), asset);
2532
+ const target = attempts.find((attempt2) => attempt2.id === fields.attemptId);
2533
+ if (!target) throw new LineageError(`Attempt ${fields.attemptId} is not in ${fields.nodeAssetId}`, 404);
2247
2534
  const timestamp = nowIso();
2248
- database.prepare("update agent_claims set status = 'released', released_at = ? where id = ?").run(timestamp, claim.id);
2249
- recordEvent(database, claim.id, "released", claim.agent_name, "Claim released by token holder.");
2250
- return { ok: true, claim: findClaimById(database, claim.id) };
2535
+ const promotedAttempt = { ...target, is_current: true, promoted_at: timestamp };
2536
+ if (!fields.confirmWrite) {
2537
+ return {
2538
+ ok: true,
2539
+ dryRun: true,
2540
+ project,
2541
+ root_asset_id: fields.rootAssetId,
2542
+ node_asset_id: fields.nodeAssetId,
2543
+ attempt: promotedAttempt,
2544
+ attempts: attempts.map((attempt2) => ({ ...attempt2, is_current: attempt2.id === target.id, promoted_at: attempt2.id === target.id ? timestamp : attempt2.promoted_at })),
2545
+ fetchedAt: timestamp
2546
+ };
2547
+ }
2548
+ database.exec("BEGIN IMMEDIATE");
2549
+ try {
2550
+ database.prepare("update asset_attempts set is_current = 0 where project_id = ? and node_asset_id = ?").run(project, fields.nodeAssetId);
2551
+ if (target.source !== "initial") {
2552
+ const result = database.prepare(`
2553
+ update asset_attempts
2554
+ set is_current = 1, promoted_at = ?
2555
+ where project_id = ? and node_asset_id = ? and id = ?
2556
+ `).run(timestamp, project, fields.nodeAssetId, target.id);
2557
+ if (result.changes !== 1) throw new LineageError(`Attempt ${fields.attemptId} is not in ${fields.nodeAssetId}`, 404);
2558
+ }
2559
+ database.exec("COMMIT");
2560
+ } catch (error) {
2561
+ database.exec("ROLLBACK");
2562
+ throw error;
2563
+ }
2564
+ const refreshedRows = database.prepare(`
2565
+ select * from asset_attempts
2566
+ where project_id = ? and node_asset_id = ?
2567
+ order by attempt_index desc, created_at desc
2568
+ `).all(project, fields.nodeAssetId);
2569
+ const refreshedAttempts = withImplicitAttempt(refreshedRows.map(attemptFrom), asset);
2570
+ const attempt = refreshedAttempts.find((item) => item.id === target.id) || promotedAttempt;
2571
+ return {
2572
+ ok: true,
2573
+ project,
2574
+ root_asset_id: fields.rootAssetId,
2575
+ node_asset_id: fields.nodeAssetId,
2576
+ attempt,
2577
+ attempts: refreshedAttempts,
2578
+ fetchedAt: nowIso()
2579
+ };
2251
2580
  } finally {
2252
2581
  database.close();
2253
2582
  }
2254
2583
  }
2255
- function releaseStaleAgentClaim(project, claimId, fields) {
2256
- if (!fields.confirmWrite) throw new AgentClaimError("Releasing a stale agent claim requires confirmWrite=true.", 400, "confirm_write_required");
2257
- if (!fields.reason?.trim()) throw new AgentClaimError("Releasing a stale agent claim requires a reason.", 400, "reason_required");
2584
+ function markLineageRerollRequest(project, fields) {
2258
2585
  const database = lineageDb();
2259
2586
  try {
2260
- expireActiveClaims(database);
2261
- const claim = findClaimById(database, claimId, project);
2262
- if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
2263
- if (claim.status !== "active" || claim.derived_state !== "stale") {
2264
- throw new AgentClaimError("Only stale active claims can be released without the claim token.", 409, "claim_not_stale", [claim]);
2265
- }
2587
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
2588
+ const existing = database.prepare(`
2589
+ select * from asset_reroll_requests
2590
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
2591
+ order by created_at desc limit 1
2592
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
2266
2593
  const timestamp = nowIso();
2267
- database.prepare("update agent_claims set status = 'released', released_at = ?, revoked_by = ?, override_reason = ? where id = ?").run(timestamp, fields.actor || "human", fields.reason, claim.id);
2268
- recordEvent(database, claim.id, "released", fields.actor || "human", fields.reason);
2269
- return { ok: true, claim: findClaimById(database, claim.id) };
2594
+ const request = existing ? {
2595
+ ...rerollRequestFrom(existing),
2596
+ notes: fields.notes || rerollRequestFrom(existing).notes,
2597
+ requested_by: fields.requestedBy || rerollRequestFrom(existing).requested_by
2598
+ } : {
2599
+ id: rerollRequestId(project, fields.rootAssetId, fields.nodeAssetId, timestamp),
2600
+ project_id: project,
2601
+ root_asset_id: fields.rootAssetId,
2602
+ node_asset_id: fields.nodeAssetId,
2603
+ status: "pending",
2604
+ requested_by: fields.requestedBy || "human",
2605
+ notes: fields.notes,
2606
+ created_at: timestamp
2607
+ };
2608
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
2609
+ if (existing) {
2610
+ database.prepare(`
2611
+ update asset_reroll_requests
2612
+ set requested_by = ?, notes = ?
2613
+ where id = ?
2614
+ `).run(request.requested_by, request.notes || null, request.id);
2615
+ } else {
2616
+ database.prepare(`
2617
+ insert into asset_reroll_requests (id, project_id, root_asset_id, node_asset_id, status, requested_by, notes, created_at, resolved_at)
2618
+ values (?, ?, ?, ?, 'pending', ?, ?, ?, null)
2619
+ `).run(request.id, project, fields.rootAssetId, fields.nodeAssetId, request.requested_by, request.notes || null, request.created_at);
2620
+ }
2621
+ database.prepare(`
2622
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
2623
+ values (?, 'needs_revision', ?, null, ?, ?)
2624
+ on conflict(asset_id) do update set
2625
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
2626
+ ignored_at = excluded.ignored_at, notes = coalesce(excluded.notes, asset_reviews.notes), updated_at = excluded.updated_at
2627
+ `).run(fields.nodeAssetId, timestamp, request.notes || null, timestamp);
2628
+ return { ok: true, request: rerollRequestFrom(database.prepare("select * from asset_reroll_requests where id = ?").get(request.id)) };
2270
2629
  } finally {
2271
2630
  database.close();
2272
2631
  }
2273
2632
  }
2274
- function revokeAgentClaim(project, claimId, fields) {
2275
- if (!fields.confirmWrite) throw new AgentClaimError("Revoking an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
2276
- if (!fields.reason?.trim()) throw new AgentClaimError("Revoking an agent claim requires a reason.", 400, "reason_required");
2633
+ function clearLineageRerollRequest(project, fields) {
2277
2634
  const database = lineageDb();
2278
2635
  try {
2279
- expireActiveClaims(database);
2280
- const claim = findClaimById(database, claimId, project);
2281
- if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
2636
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
2637
+ const existing = database.prepare(`
2638
+ select * from asset_reroll_requests
2639
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
2640
+ order by created_at desc limit 1
2641
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
2642
+ if (!existing) throw new LineageError(`No pending re-roll request for ${fields.nodeAssetId}`, 404);
2282
2643
  const timestamp = nowIso();
2644
+ const request = { ...rerollRequestFrom(existing), status: "cancelled", resolved_at: timestamp };
2645
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
2283
2646
  database.prepare(`
2284
- update agent_claims set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
2647
+ update asset_reroll_requests
2648
+ set status = 'cancelled', resolved_at = ?
2285
2649
  where id = ?
2286
- `).run(timestamp, fields.actor || "human", fields.reason, claim.id);
2287
- recordEvent(database, claim.id, "revoked", fields.actor || "human", fields.reason);
2288
- return { ok: true, claim: findClaimById(database, claim.id) };
2650
+ `).run(timestamp, request.id);
2651
+ return { ok: true, request };
2289
2652
  } finally {
2290
2653
  database.close();
2291
2654
  }
2292
2655
  }
2293
- function transferAgentClaim(project, claimId, fields) {
2294
- if (!fields.confirmWrite) throw new AgentClaimError("Transferring an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
2295
- const toAgentName = fields.toAgentName.trim();
2296
- if (!toAgentName) throw new AgentClaimError("Transfer requires toAgentName.", 400, "agent_name_required");
2656
+ function updateSelectedAsset(project, fields) {
2297
2657
  const database = lineageDb();
2298
- try {
2299
- expireActiveClaims(database);
2300
- const claim = findClaimById(database, claimId, project);
2301
- if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
2302
- if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
2303
- database.prepare("update agent_claims set agent_name = ? where id = ?").run(toAgentName, claim.id);
2304
- recordEvent(database, claim.id, "transferred", fields.actor || "human", fields.reason || `Transferred claim to ${toAgentName}.`, { to_agent_name: toAgentName });
2305
- return { ok: true, claim: findClaimById(database, claim.id) };
2306
- } finally {
2658
+ const inputAssetIds = normalizeSelectionInput(fields);
2659
+ const root = fields.rootAssetId || (inputAssetIds[0] ? rootFor(database, project, inputAssetIds[0]) : "");
2660
+ if (!root) throw new LineageError("Selection requires rootAssetId or assetId");
2661
+ requireAsset2(database, project, root);
2662
+ for (const assetId of inputAssetIds) requireAsset2(database, project, assetId);
2663
+ const mode = fields.mode || "replace";
2664
+ const limit = fields.maxSelections || LINEAGE_NEXT_VARIATION_LIMIT;
2665
+ if (!fields.confirmWrite) {
2307
2666
  database.close();
2667
+ return { ok: true, dryRun: true, root_asset_id: root, asset_ids: inputAssetIds, mode, clear: Boolean(fields.clear), max_selections: limit };
2308
2668
  }
2309
- }
2310
- function validateAgentClaimForWrite(fields) {
2311
- if (fields.dangerLevel === "danger" && !fields.confirmWrite) {
2312
- return denied("human_confirmation_required", "Dangerous write requires explicit human confirmation.");
2669
+ const current = selectedRows(database, project, root);
2670
+ let nextIds = current.map((row) => row.asset_id);
2671
+ if (fields.clear) {
2672
+ nextIds = [];
2673
+ } else if (mode === "replace") {
2674
+ nextIds = inputAssetIds;
2675
+ } else if (mode === "add") {
2676
+ nextIds = [...nextIds, ...inputAssetIds];
2677
+ } else if (mode === "remove") {
2678
+ nextIds = nextIds.filter((assetId) => !inputAssetIds.includes(assetId));
2679
+ } else if (mode === "toggle") {
2680
+ for (const assetId of inputAssetIds) {
2681
+ nextIds = nextIds.includes(assetId) ? nextIds.filter((id) => id !== assetId) : [...nextIds, assetId];
2682
+ }
2313
2683
  }
2314
- if (!fields.claimToken) return denied("claim_required", "Mutating agent write requires a matching claim token.");
2684
+ nextIds = [...new Set(nextIds)];
2685
+ if (!fields.clear && inputAssetIds.length === 0) throw new LineageError("Selection set requires assetId or assetIds");
2686
+ if (nextIds.length > limit) throw new LineageError(`Select at most ${limit} assets for next variation`);
2687
+ database.prepare("delete from asset_selections where project_id = ? and root_asset_id = ?").run(project, root);
2688
+ const timestamp = nowIso();
2689
+ const insert = database.prepare(`
2690
+ insert into asset_selections (id, project_id, root_asset_id, asset_id, position, notes, selected_at)
2691
+ values (?, ?, ?, ?, ?, ?, ?)
2692
+ `);
2693
+ nextIds.forEach((assetId, position) => {
2694
+ const existing = current.find((row) => row.asset_id === assetId);
2695
+ const notes = inputAssetIds.includes(assetId) ? fields.notes || existing?.notes : existing?.notes;
2696
+ insert.run(selectionId(project, root, assetId), project, root, assetId, position, notes || null, timestamp);
2697
+ });
2698
+ database.close();
2699
+ const message = nextIds.length === 0 ? `Cleared selected assets for ${root}` : `Selected ${nextIds.length} asset${nextIds.length === 1 ? "" : "s"} for ${root}`;
2700
+ return { ok: true, message, root_asset_id: root, asset_id: nextIds[0] || null, asset_ids: nextIds, mode };
2701
+ }
2702
+ function updateAssetReview(project, fields) {
2703
+ const allowed = /* @__PURE__ */ new Set(["unreviewed", "approved", "needs_revision", "rejected", "ignored"]);
2704
+ if (!allowed.has(fields.reviewState)) throw new LineageError(`Unsupported review state: ${fields.reviewState}`);
2315
2705
  const database = lineageDb();
2316
- try {
2317
- expireActiveClaims(database);
2318
- const row = findClaimRowByToken(database, fields.claimToken);
2319
- if (!row) return denied("claim_token_invalid", "Unknown or invalid claim token.");
2320
- const claim = rowToClaim(row);
2321
- if (claim.status !== "active") return denied("claim_not_active", `Agent claim is ${claim.status}.`);
2322
- if (new Date(claim.expires_at).getTime() <= Date.now()) return denied("claim_expired", "Agent claim has expired.");
2323
- if (claim.project !== fields.project) return denied("claim_project_mismatch", `Claim project ${claim.project} does not match ${fields.project}.`, [claim]);
2324
- if (fields.channel && claim.channel && claim.channel !== fields.channel) {
2325
- return denied("claim_channel_mismatch", `Claim channel ${claim.channel} does not match ${fields.channel}.`, [claim]);
2326
- }
2327
- if (!scopeAllowsWrite(claim, fields.scopeType, fields.targetId, fields.writeKind)) {
2328
- return denied("claim_scope_mismatch", `Claim does not cover ${fields.scopeType} ${fields.targetId}.`, [claim]);
2329
- }
2330
- recordEvent(database, claim.id, "write_allowed", claim.agent_name, `${fields.writeKind} allowed.`, {
2331
- danger_level: fields.dangerLevel,
2332
- target_id: fields.targetId,
2333
- write_kind: fields.writeKind
2334
- });
2335
- return { ok: true, claim, warnings: [] };
2336
- } finally {
2706
+ requireAsset2(database, project, fields.assetId);
2707
+ if (!fields.confirmWrite) {
2337
2708
  database.close();
2709
+ return { ok: true, dryRun: true, asset_id: fields.assetId, review_state: fields.reviewState, notes: fields.notes };
2338
2710
  }
2711
+ const timestamp = nowIso();
2712
+ database.prepare(`
2713
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
2714
+ values (?, ?, ?, ?, ?, ?)
2715
+ on conflict(asset_id) do update set
2716
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
2717
+ ignored_at = excluded.ignored_at, notes = excluded.notes, updated_at = excluded.updated_at
2718
+ `).run(fields.assetId, fields.reviewState, timestamp, fields.reviewState === "ignored" ? timestamp : null, fields.notes || null, timestamp);
2719
+ database.close();
2720
+ return { ok: true, message: `Marked ${fields.assetId} ${fields.reviewState}`, asset_id: fields.assetId, review_state: fields.reviewState };
2339
2721
  }
2340
2722
 
2341
2723
  // src/server/assetLineageHandoff.ts
@@ -2396,14 +2778,15 @@ function linkSelectedLineageChild(project, fields) {
2396
2778
  const next = getLineageNextAsset(project, fields.rootAssetId);
2397
2779
  if (!next.next_asset) throw new LineageError("Cannot link child until a next base is selected or unambiguous");
2398
2780
  if (fields.confirmWrite) {
2781
+ const claimContext = getLineageWriteClaimContext(project, next.next_asset.asset_id);
2399
2782
  const validation = validateAgentClaimForWrite({
2400
- channel: next.next_asset.channel,
2783
+ channel: claimContext.channel,
2401
2784
  claimToken: fields.claimToken,
2402
2785
  confirmWrite: fields.confirmWrite,
2403
2786
  dangerLevel: "enforce",
2404
2787
  project,
2405
2788
  scopeType: "lineage_workspace",
2406
- targetId: lineageWorkspaceId(project, next.root_asset_id),
2789
+ targetId: lineageWorkspaceId(project, claimContext.rootAssetId),
2407
2790
  writeKind: "link_child"
2408
2791
  });
2409
2792
  if (!validation.ok) throw new AgentClaimError(validation.message, validation.code === "claim_required" ? 401 : 403, validation.code, validation.conflicts);
@@ -2429,6 +2812,10 @@ function requireAsset3(database, project, assetId) {
2429
2812
  const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
2430
2813
  if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
2431
2814
  }
2815
+ function assetChannel2(database, project, assetId) {
2816
+ const row = database.prepare("select channel from assets where project_id = ? and id = ?").get(project, assetId);
2817
+ return row?.channel;
2818
+ }
2432
2819
  function parentOf2(database, project, assetId) {
2433
2820
  const row = database.prepare("select parent_asset_id from asset_edges where project_id = ? and child_asset_id = ? order by created_at limit 1").get(project, assetId);
2434
2821
  return row?.parent_asset_id;
@@ -2475,6 +2862,19 @@ function removeLineageNode(project, fields) {
2475
2862
  database.close();
2476
2863
  throw new LineageError(`Asset ${fields.assetId} is not in lineage rooted at ${root}`, 404);
2477
2864
  }
2865
+ try {
2866
+ requireLineageWorkspaceClaimForWrite({
2867
+ channel: assetChannel2(database, project, root),
2868
+ claimToken: fields.claimToken,
2869
+ confirmWrite: fields.confirmWrite,
2870
+ project,
2871
+ rootAssetId: root,
2872
+ writeKind: "lineage_remove_node"
2873
+ });
2874
+ } catch (error) {
2875
+ database.close();
2876
+ throw error;
2877
+ }
2478
2878
  const parentEdges = snapshot.edges.filter((edge) => edge.child_asset_id === fields.assetId);
2479
2879
  const childEdges = snapshot.edges.filter((edge) => edge.parent_asset_id === fields.assetId);
2480
2880
  const removedIds = [...parentEdges, ...childEdges].map((edge) => edge.id);
@@ -4215,7 +4615,7 @@ function updateContentPost(project, fields) {
4215
4615
  const timestamp = nowIso();
4216
4616
  try {
4217
4617
  const current = findContentPost(database, project, postId);
4218
- if (fields.phase) requireContentPostClaim(project, current, fields.claimToken, "content_post_phase");
4618
+ requireContentPostClaim(project, current, fields.claimToken, fields.phase ? "content_post_phase" : "content_post_update");
4219
4619
  if (fields.batchId) {
4220
4620
  const batch = database.prepare("select id from content_batches where project_id = ? and id = ?").get(project, fields.batchId);
4221
4621
  if (!batch) throw new ContentBatchError(`Unknown content batch: ${fields.batchId}`, 404);
@@ -4389,7 +4789,8 @@ function setContentTarget(project, fields) {
4389
4789
  const database = lineageDb();
4390
4790
  const timestamp = nowIso();
4391
4791
  try {
4392
- findPost(database, project, postId);
4792
+ const post = findPost(database, project, postId);
4793
+ requireContentPostClaim(project, post, fields.claimToken, "content_target_set");
4393
4794
  database.prepare(`
4394
4795
  insert into content_targets (project_id, post_id, notes, selected_at, updated_at)
4395
4796
  values (?, ?, ?, ?, ?)
@@ -4402,10 +4803,19 @@ function setContentTarget(project, fields) {
4402
4803
  }
4403
4804
  return { ok: true, message: `Selected content target ${postId}`, ...getContentTarget(project) };
4404
4805
  }
4405
- function clearContentTarget(project, confirmWrite) {
4806
+ function clearContentTarget(project, confirmWrite, claimToken) {
4406
4807
  if (!confirmWrite) return { ok: true, dryRun: true, message: "Would clear selected content target" };
4407
4808
  const database = lineageDb();
4408
4809
  try {
4810
+ const row = database.prepare("select post_id from content_targets where project_id = ?").get(project);
4811
+ if (row?.post_id) {
4812
+ try {
4813
+ const post = findPost(database, project, String(row.post_id));
4814
+ requireContentPostClaim(project, post, claimToken, "content_target_clear");
4815
+ } catch (error) {
4816
+ if (!(error instanceof ContentBatchError && error.status === 404)) throw error;
4817
+ }
4818
+ }
4409
4819
  database.prepare("delete from content_targets where project_id = ?").run(project);
4410
4820
  } finally {
4411
4821
  database.close();
@@ -4609,7 +5019,7 @@ function itemFromFile(file) {
4609
5019
  };
4610
5020
  }
4611
5021
  function demoMarkdownItems(kind) {
4612
- const root = join7(repoRoot, "demo-project", "channels");
5022
+ const root = process.env.LINEAGE_CONTENT_SOURCE_ROOT || join7(repoRoot, "demo-project", "channels");
4613
5023
  if (!existsSync4(root)) return [];
4614
5024
  return walk(root).map(itemFromFile).filter((item) => Boolean(item)).filter((item) => kind === "all" || `${item.kind}s` === kind).sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
4615
5025
  }
@@ -4873,13 +5283,14 @@ function contentBatchRouter(projectFrom2) {
4873
5283
  });
4874
5284
  router.post("/target", (req, res) => {
4875
5285
  res.json(setContentTarget(projectFrom2(bodyProjectShape(req)), {
5286
+ claimToken: claimTokenFromRequest2(req),
4876
5287
  confirmWrite: boolBody2(req, "confirmWrite"),
4877
5288
  notes: stringBody2(req, "notes"),
4878
5289
  postId: stringBody2(req, "postId") || ""
4879
5290
  }));
4880
5291
  });
4881
5292
  router.post("/target/clear", (req, res) => {
4882
- res.json(clearContentTarget(projectFrom2(bodyProjectShape(req)), boolBody2(req, "confirmWrite")));
5293
+ res.json(clearContentTarget(projectFrom2(bodyProjectShape(req)), boolBody2(req, "confirmWrite"), claimTokenFromRequest2(req)));
4883
5294
  });
4884
5295
  router.get("/batches/:batchId", (req, res) => {
4885
5296
  res.json(getContentBatch(projectFrom2(req), req.params.batchId));
@@ -5010,7 +5421,7 @@ function loadGenerationJob(database, project, id) {
5010
5421
  project_id: String(row.project_id),
5011
5422
  provider: row.provider,
5012
5423
  adapter_version: String(row.adapter_version),
5013
- source_mode: "lineage_selection",
5424
+ source_mode: String(row.source_mode),
5014
5425
  root_asset_id: String(row.root_asset_id),
5015
5426
  prompt: String(row.prompt),
5016
5427
  expected_output_count: Number(row.expected_output_count),
@@ -5119,6 +5530,15 @@ function swissifierRelativePath(manifest, asset) {
5119
5530
  function swissifierFilePath(manifest, asset) {
5120
5531
  return join8(repoRoot, ".asset-scratch", swissifierRelativePath(manifest, asset));
5121
5532
  }
5533
+ function swissifierRerollRelativePath(manifest, attempt) {
5534
+ return join8(manifest.media.target_dir, "reroll-attempts", attempt.file);
5535
+ }
5536
+ function swissifierRerollFilePath(manifest, attempt) {
5537
+ return join8(repoRoot, ".asset-scratch", swissifierRerollRelativePath(manifest, attempt));
5538
+ }
5539
+ function swissifierRerollFixturePath(attempt) {
5540
+ return join8(dirname3(swissifierManifestPath), "swissifier-rerolls", attempt.file);
5541
+ }
5122
5542
  function swissifierSourcePath(manifest, asset, sourceDir) {
5123
5543
  const direct = join8(sourceDir, asset.file);
5124
5544
  if (existsSync5(direct)) return direct;
@@ -5416,6 +5836,27 @@ function writeDemoFiles(project) {
5416
5836
  for (const asset of demoAssets) writeDemoFile(project, asset);
5417
5837
  return ids;
5418
5838
  }
5839
+ function swissifierRerollAsset(attempt) {
5840
+ const source = swissifierRerollFixturePath(attempt);
5841
+ if (!existsSync5(source)) throw new Error(`Missing Swissifier re-roll fixture media: ${attempt.file}`);
5842
+ const body = readFileSync4(source);
5843
+ if (body.length !== attempt.size_bytes) throw new Error(`Unexpected Swissifier re-roll fixture size for ${attempt.file}`);
5844
+ const checksumSha256 = sha256Hex(body);
5845
+ if (checksumSha256 !== attempt.checksum_sha256) throw new Error(`Checksum mismatch for Swissifier re-roll fixture: ${attempt.file}`);
5846
+ return {
5847
+ assetId: `local-${checksumSha256.slice(0, 12)}`,
5848
+ checksumSha256,
5849
+ sizeBytes: body.length
5850
+ };
5851
+ }
5852
+ function writeSwissifierRerollFiles(manifest) {
5853
+ for (const attempt of manifest.reroll_attempts || []) {
5854
+ swissifierRerollAsset(attempt);
5855
+ const target = swissifierRerollFilePath(manifest, attempt);
5856
+ mkdirSync5(dirname3(target), { recursive: true });
5857
+ copyFileSync2(swissifierRerollFixturePath(attempt), target);
5858
+ }
5859
+ }
5419
5860
  function upsertDemoAssets(project, ids) {
5420
5861
  const database = lineageDb();
5421
5862
  const timestamp = nowIso();
@@ -5506,10 +5947,100 @@ function upsertSwissifierAssets(project, manifest) {
5506
5947
  );
5507
5948
  reviewStatement.run(asset.asset_id, timestamp);
5508
5949
  }
5950
+ for (const attempt of manifest.reroll_attempts || []) {
5951
+ const generated = swissifierRerollAsset(attempt);
5952
+ assetStatement.run(
5953
+ generated.assetId,
5954
+ project,
5955
+ swissifierRerollRelativePath(manifest, attempt),
5956
+ generated.checksumSha256,
5957
+ attempt.title,
5958
+ "planned",
5959
+ "local",
5960
+ manifest.campaign,
5961
+ manifest.audience,
5962
+ generated.sizeBytes,
5963
+ attempt.content_type,
5964
+ timestamp,
5965
+ timestamp,
5966
+ timestamp
5967
+ );
5968
+ reviewStatement.run(generated.assetId, timestamp);
5969
+ }
5970
+ } finally {
5971
+ database.close();
5972
+ }
5973
+ const attemptCount = manifest.reroll_attempts?.length || 0;
5974
+ return { catalog: 0, local: manifest.assets.length + attemptCount, total: manifest.assets.length + attemptCount };
5975
+ }
5976
+ function upsertSwissifierRerollAttempts(project, manifest) {
5977
+ const attempts = manifest.reroll_attempts || [];
5978
+ if (attempts.length === 0) return { total: 0 };
5979
+ const database = lineageDb();
5980
+ const timestamp = nowIso();
5981
+ try {
5982
+ database.exec("BEGIN IMMEDIATE");
5983
+ try {
5984
+ const nodes = new Set(attempts.map((attempt) => attempt.node_asset_id));
5985
+ for (const nodeAssetId of nodes) {
5986
+ database.prepare("update asset_attempts set is_current = 0 where project_id = ? and node_asset_id = ?").run(project, nodeAssetId);
5987
+ }
5988
+ const statement = database.prepare(`
5989
+ insert into asset_attempts (
5990
+ id, project_id, node_asset_id, asset_id, attempt_index, source, prompt, generation_job_id,
5991
+ file_path, checksum_sha256, created_at, promoted_at, is_current
5992
+ ) values (?, ?, ?, ?, ?, 'reroll', ?, ?, ?, ?, ?, ?, ?)
5993
+ on conflict(id) do update set
5994
+ asset_id = excluded.asset_id,
5995
+ source = excluded.source,
5996
+ prompt = excluded.prompt,
5997
+ generation_job_id = excluded.generation_job_id,
5998
+ file_path = excluded.file_path,
5999
+ checksum_sha256 = excluded.checksum_sha256,
6000
+ promoted_at = excluded.promoted_at,
6001
+ is_current = excluded.is_current
6002
+ `);
6003
+ for (const attempt of attempts) {
6004
+ const generated = swissifierRerollAsset(attempt);
6005
+ statement.run(
6006
+ `${project}:${attempt.node_asset_id}:attempt:${attempt.attempt_index}`,
6007
+ project,
6008
+ attempt.node_asset_id,
6009
+ generated.assetId,
6010
+ attempt.attempt_index,
6011
+ attempt.prompt,
6012
+ attempt.generation_job_id,
6013
+ swissifierRerollRelativePath(manifest, attempt),
6014
+ generated.checksumSha256,
6015
+ timestamp,
6016
+ timestamp,
6017
+ attempt.current ? 1 : 0
6018
+ );
6019
+ }
6020
+ for (const nodeAssetId of nodes) {
6021
+ const currentCount = database.prepare("select count(*) count from asset_attempts where project_id = ? and node_asset_id = ? and is_current = 1").get(project, nodeAssetId);
6022
+ if (currentCount.count === 0) {
6023
+ database.prepare(`
6024
+ update asset_attempts
6025
+ set is_current = 1, promoted_at = ?
6026
+ where id = (
6027
+ select id from asset_attempts
6028
+ where project_id = ? and node_asset_id = ?
6029
+ order by attempt_index desc
6030
+ limit 1
6031
+ )
6032
+ `).run(timestamp, project, nodeAssetId);
6033
+ }
6034
+ }
6035
+ database.exec("COMMIT");
6036
+ } catch (error) {
6037
+ database.exec("ROLLBACK");
6038
+ throw error;
6039
+ }
5509
6040
  } finally {
5510
6041
  database.close();
5511
6042
  }
5512
- return { catalog: 0, local: manifest.assets.length, total: manifest.assets.length };
6043
+ return { total: attempts.length };
5513
6044
  }
5514
6045
  function seedDemoLineageWorkspace(project, fields) {
5515
6046
  const ids = fields.confirmWrite ? writeDemoFiles(project) : demoAssetIds();
@@ -5568,10 +6099,12 @@ function seedSwissifierRichDemoWorkspace(project, fields) {
5568
6099
  media_status: swissifierRichDemoMediaStatus(project)
5569
6100
  };
5570
6101
  }
6102
+ writeSwissifierRerollFiles(manifest);
5571
6103
  const summary = upsertSwissifierAssets(project, manifest);
5572
6104
  for (const edge of manifest.edges) {
5573
6105
  linkLineageAssets(project, { parentAssetId: edge.parent, childAssetId: edge.child, confirmWrite: true });
5574
6106
  }
6107
+ const reroll_attempts = upsertSwissifierRerollAttempts(project, manifest);
5575
6108
  updateSelectedAsset(project, {
5576
6109
  assetIds: manifest.selected_asset_ids,
5577
6110
  confirmWrite: true,
@@ -5599,6 +6132,7 @@ function seedSwissifierRichDemoWorkspace(project, fields) {
5599
6132
  media_status: swissifierRichDemoMediaStatus(project),
5600
6133
  root_asset_id: manifest.root_asset_id,
5601
6134
  selected_asset_ids: manifest.selected_asset_ids,
6135
+ reroll_attempts,
5602
6136
  summary,
5603
6137
  workspace
5604
6138
  };
@@ -5805,6 +6339,36 @@ app.get(
5805
6339
  })
5806
6340
  );
5807
6341
  registerLineageWorkspaceRoutes(app, projectFrom, asyncRoute);
6342
+ app.get("/api/lineage/:rootAssetId/rerolls", asyncRoute((req, res) => {
6343
+ res.json(listLineageRerollRequests(projectFrom(req), req.params.rootAssetId));
6344
+ }));
6345
+ app.post("/api/lineage/:rootAssetId/rerolls/:nodeAssetId", asyncRoute((req, res) => {
6346
+ res.json(markLineageRerollRequest(projectFrom(req), {
6347
+ rootAssetId: req.params.rootAssetId,
6348
+ nodeAssetId: req.params.nodeAssetId,
6349
+ notes: typeof req.body.notes === "string" ? req.body.notes : void 0,
6350
+ requestedBy: req.body.requestedBy === "agent" || req.body.requestedBy === "system" ? req.body.requestedBy : "human",
6351
+ confirmWrite: req.body.confirmWrite === true
6352
+ }));
6353
+ }));
6354
+ app.post("/api/lineage/:rootAssetId/rerolls/:nodeAssetId/cancel", asyncRoute((req, res) => {
6355
+ res.json(clearLineageRerollRequest(projectFrom(req), {
6356
+ rootAssetId: req.params.rootAssetId,
6357
+ nodeAssetId: req.params.nodeAssetId,
6358
+ confirmWrite: req.body.confirmWrite === true
6359
+ }));
6360
+ }));
6361
+ app.get("/api/lineage/:rootAssetId/attempts/:nodeAssetId", asyncRoute((req, res) => {
6362
+ res.json(getLineageAttempts(projectFrom(req), req.params.rootAssetId, req.params.nodeAssetId));
6363
+ }));
6364
+ app.post("/api/lineage/:rootAssetId/attempts/:nodeAssetId/promote", asyncRoute((req, res) => {
6365
+ res.json(promoteLineageAttempt(projectFrom(req), {
6366
+ rootAssetId: req.params.rootAssetId,
6367
+ nodeAssetId: req.params.nodeAssetId,
6368
+ attemptId: String(req.body.attemptId || ""),
6369
+ confirmWrite: req.body.confirmWrite === true
6370
+ }));
6371
+ }));
5808
6372
  app.get("/api/lineage/:assetId", asyncRoute((req, res) => {
5809
6373
  res.json(getLineageSnapshot(projectFrom(req), req.params.assetId));
5810
6374
  }));
@@ -5815,13 +6379,14 @@ app.post(
5815
6379
  linkLineageAssets(projectFrom(req), {
5816
6380
  parentAssetId: String(req.body.parentAssetId || ""),
5817
6381
  childAssetId: String(req.body.childAssetId || ""),
5818
- confirmWrite: req.body.confirmWrite === true
6382
+ confirmWrite: req.body.confirmWrite === true,
6383
+ claimToken: claimTokenFromRequest(req)
5819
6384
  })
5820
6385
  );
5821
6386
  })
5822
6387
  );
5823
6388
  app.post("/api/lineage/remove-node", asyncRoute((req, res) => {
5824
- res.json(removeLineageNode(projectFrom(req), { assetId: String(req.body.assetId || ""), rootAssetId: typeof req.body.rootAssetId === "string" ? req.body.rootAssetId : void 0, confirmWrite: req.body.confirmWrite === true }));
6389
+ res.json(removeLineageNode(projectFrom(req), { assetId: String(req.body.assetId || ""), rootAssetId: typeof req.body.rootAssetId === "string" ? req.body.rootAssetId : void 0, confirmWrite: req.body.confirmWrite === true, claimToken: claimTokenFromRequest(req) }));
5825
6390
  }));
5826
6391
  app.post(
5827
6392
  "/api/lineage/layout",
@@ -5835,7 +6400,8 @@ app.post(
5835
6400
  x: Number(position.x),
5836
6401
  y: Number(position.y)
5837
6402
  })),
5838
- confirmWrite: req.body.confirmWrite === true
6403
+ confirmWrite: req.body.confirmWrite === true,
6404
+ claimToken: claimTokenFromRequest(req)
5839
6405
  })
5840
6406
  );
5841
6407
  })