@mean-weasel/lineage 0.1.4 → 0.1.5

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
@@ -1587,755 +1587,852 @@ function activeLineageWorkspaceRoot(project) {
1587
1587
  }
1588
1588
  }
1589
1589
 
1590
- // src/server/assetLineage.ts
1591
- var LineageError = class extends Error {
1592
- constructor(message, status = 400) {
1590
+ // src/server/agentClaims.ts
1591
+ import { createHash as createHash2, randomBytes, timingSafeEqual } from "node:crypto";
1592
+ var defaultTtlSeconds = 20 * 60;
1593
+ var idleAfterSeconds = 5 * 60;
1594
+ var staleAfterSeconds = 15 * 60;
1595
+ var scopes = /* @__PURE__ */ new Set(["lineage_workspace", "content_post", "content_queue_lane", "selection_set", "project_channel"]);
1596
+ var AgentClaimError = class extends Error {
1597
+ constructor(message, status = 400, code = "agent_claim_error", conflicts = []) {
1593
1598
  super(message);
1594
1599
  this.status = status;
1600
+ this.code = code;
1601
+ this.conflicts = conflicts;
1595
1602
  }
1596
1603
  status;
1604
+ code;
1605
+ conflicts;
1597
1606
  };
1598
- function isLineageError(error) {
1599
- return error instanceof LineageError;
1607
+ function isAgentClaimError(error) {
1608
+ return error instanceof AgentClaimError;
1600
1609
  }
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);
1610
+ function parseClaimTtl(value) {
1611
+ if (!value) return defaultTtlSeconds;
1612
+ const match = value.trim().match(/^(\d+)(s|m|h)?$/);
1613
+ if (!match) throw new AgentClaimError(`Invalid claim ttl: ${value}`);
1614
+ const amount = Number(match[1]);
1615
+ const unit = match[2] || "s";
1616
+ const multiplier = unit === "h" ? 3600 : unit === "m" ? 60 : 1;
1617
+ const seconds = amount * multiplier;
1618
+ if (!Number.isInteger(seconds) || seconds < 30 || seconds > 24 * 60 * 60) {
1619
+ throw new AgentClaimError("Claim ttl must be between 30 seconds and 24 hours");
1606
1620
  }
1607
- return assets;
1621
+ return seconds;
1608
1622
  }
1609
- function upsertProject(database, project) {
1623
+ function randomId(prefix) {
1624
+ return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString("base64url").toLowerCase()}`;
1625
+ }
1626
+ function tokenHash(token) {
1627
+ return createHash2("sha256").update(token).digest("hex");
1628
+ }
1629
+ function safeEqual(a, b) {
1630
+ const left = Buffer.from(a);
1631
+ const right = Buffer.from(b);
1632
+ return left.length === right.length && timingSafeEqual(left, right);
1633
+ }
1634
+ function expiresAtFrom(timestamp, ttlSeconds) {
1635
+ return new Date(new Date(timestamp).getTime() + ttlSeconds * 1e3).toISOString();
1636
+ }
1637
+ function metadataJson(metadata2) {
1638
+ return metadata2 ? JSON.stringify(metadata2) : null;
1639
+ }
1640
+ function parseMetadata(value) {
1641
+ if (typeof value !== "string" || !value) return void 0;
1642
+ try {
1643
+ const parsed = JSON.parse(value);
1644
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1645
+ } catch {
1646
+ return void 0;
1647
+ }
1648
+ }
1649
+ function derivedState(row, now = /* @__PURE__ */ new Date()) {
1650
+ if (row.status !== "active") return row.status === "expired" ? "expired" : "stale";
1651
+ if (new Date(row.expires_at).getTime() <= now.getTime()) return "expired";
1652
+ const ageSeconds = Math.max(0, Math.floor((now.getTime() - new Date(row.heartbeat_at).getTime()) / 1e3));
1653
+ if (ageSeconds >= staleAfterSeconds) return "stale";
1654
+ if (ageSeconds >= idleAfterSeconds) return "idle";
1655
+ return "active";
1656
+ }
1657
+ function rowToClaim(row, now = /* @__PURE__ */ new Date()) {
1658
+ const heartbeatAt = String(row.heartbeat_at);
1659
+ return {
1660
+ id: String(row.id),
1661
+ project: String(row.project_id),
1662
+ channel: typeof row.channel === "string" ? row.channel : void 0,
1663
+ scope_type: String(row.scope_type),
1664
+ target_id: String(row.target_id),
1665
+ target_title: typeof row.target_title === "string" ? row.target_title : void 0,
1666
+ agent_id: typeof row.agent_id === "string" ? row.agent_id : void 0,
1667
+ agent_name: String(row.agent_name),
1668
+ agent_kind: String(row.agent_kind),
1669
+ thread_id: typeof row.thread_id === "string" ? row.thread_id : void 0,
1670
+ status: String(row.status),
1671
+ created_at: String(row.created_at),
1672
+ heartbeat_at: heartbeatAt,
1673
+ expires_at: String(row.expires_at),
1674
+ released_at: typeof row.released_at === "string" ? row.released_at : void 0,
1675
+ revoked_at: typeof row.revoked_at === "string" ? row.revoked_at : void 0,
1676
+ revoked_by: typeof row.revoked_by === "string" ? row.revoked_by : void 0,
1677
+ override_reason: typeof row.override_reason === "string" ? row.override_reason : void 0,
1678
+ metadata: parseMetadata(row.metadata_json),
1679
+ heartbeat_age_seconds: Math.max(0, Math.floor((now.getTime() - new Date(heartbeatAt).getTime()) / 1e3)),
1680
+ derived_state: derivedState({
1681
+ expires_at: String(row.expires_at),
1682
+ heartbeat_at: heartbeatAt,
1683
+ status: String(row.status)
1684
+ }, now)
1685
+ };
1686
+ }
1687
+ function eventToRow(row) {
1688
+ return {
1689
+ claim_id: String(row.claim_id),
1690
+ event_type: String(row.event_type),
1691
+ actor: typeof row.actor === "string" ? row.actor : void 0,
1692
+ message: typeof row.message === "string" ? row.message : void 0,
1693
+ created_at: String(row.created_at),
1694
+ metadata: parseMetadata(row.metadata_json)
1695
+ };
1696
+ }
1697
+ function ensureProject3(database, project) {
1610
1698
  const timestamp = nowIso();
1611
1699
  database.prepare(`
1612
- insert into projects (id, product, catalog_path, created_at, updated_at)
1613
- values (?, ?, ?, ?, ?)
1700
+ insert into projects (id, product, created_at, updated_at)
1701
+ values (?, ?, ?, ?)
1614
1702
  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);
1703
+ `).run(project, project, timestamp, timestamp);
1616
1704
  }
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
- );
1705
+ function recordEvent(database, claimId, eventType, actor, message, metadata2) {
1650
1706
  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);
1707
+ insert into agent_claim_events (id, claim_id, event_type, actor, message, created_at, metadata_json)
1708
+ values (?, ?, ?, ?, ?, ?, ?)
1709
+ `).run(randomId("claim_event"), claimId, eventType, actor || null, message || null, nowIso(), metadataJson(metadata2));
1655
1710
  }
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() };
1711
+ function expireActiveClaims(database) {
1712
+ const timestamp = nowIso();
1713
+ const expired = database.prepare(`
1714
+ select id from agent_claims where status = 'active' and expires_at <= ?
1715
+ `).all(timestamp);
1716
+ if (expired.length === 0) return;
1717
+ database.prepare(`
1718
+ update agent_claims set status = 'expired' where status = 'active' and expires_at <= ?
1719
+ `).run(timestamp);
1720
+ for (const claim of expired) recordEvent(database, claim.id, "expired", "system", "Claim expired after missed heartbeat.");
1664
1721
  }
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);
1722
+ function normalizeScope(scopeType) {
1723
+ if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
1724
+ return scopeType;
1668
1725
  }
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;
1726
+ function channelOverlaps(left, right) {
1727
+ return !left || !right || left === right;
1672
1728
  }
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;
1729
+ function claimOverlaps(candidate, existing) {
1730
+ if (candidate.project !== existing.project) return false;
1731
+ if (!channelOverlaps(candidate.channel, existing.channel)) return false;
1732
+ if (candidate.scope_type === existing.scope_type && candidate.target_id === existing.target_id) return true;
1733
+ return candidate.scope_type === "project_channel" || existing.scope_type === "project_channel";
1683
1734
  }
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;
1735
+ function activeClaims(database, project) {
1736
+ 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();
1737
+ return rows.map((row) => rowToClaim(row));
1687
1738
  }
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;
1739
+ function findClaimById(database, claimId, project) {
1740
+ 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);
1741
+ return row ? rowToClaim(row) : null;
1697
1742
  }
1698
- function edgeId(project, parent, child) {
1699
- return `${project}:${parent}:derived_from:${child}`;
1743
+ function findClaimRowByToken(database, claimToken) {
1744
+ const claimId = claimToken.split(".")[0];
1745
+ const row = database.prepare("select * from agent_claims where id = ?").get(claimId);
1746
+ if (!row) return null;
1747
+ return safeEqual(String(row.token_hash), tokenHash(claimToken)) ? row : null;
1700
1748
  }
1701
- function canPreviewLocally(mediaType, localPath) {
1702
- return Boolean(localPath && ["image", "video", "gif"].includes(mediaType));
1749
+ function denied(code, message, conflicts = []) {
1750
+ return { ok: false, code, message, conflicts };
1703
1751
  }
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()}`;
1752
+ function scopeAllowsWrite(claim, scopeType, targetId, writeKind) {
1753
+ if (claim.scope_type === scopeType && claim.target_id === targetId) return true;
1754
+ if (claim.scope_type === "project_channel") return true;
1755
+ if (claim.scope_type === "lineage_workspace" && scopeType === "lineage_workspace") return claim.target_id === targetId;
1756
+ if (claim.scope_type === "content_queue_lane" && writeKind === "content_queue_next") return true;
1757
+ return false;
1708
1758
  }
1709
- function linkLineageAssets(project, fields) {
1759
+ function createAgentClaim(fields) {
1760
+ const project = fields.project.trim();
1761
+ const targetId = fields.targetId.trim();
1762
+ const agentName = fields.agentName.trim();
1763
+ if (!project) throw new AgentClaimError("Agent claim requires project");
1764
+ if (!targetId) throw new AgentClaimError("Agent claim requires target");
1765
+ if (!agentName) throw new AgentClaimError("Agent claim requires agent name");
1766
+ const scopeType = normalizeScope(fields.scopeType);
1767
+ const ttlSeconds = fields.ttlSeconds || defaultTtlSeconds;
1710
1768
  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) {
1769
+ try {
1770
+ ensureProject3(database, project);
1771
+ expireActiveClaims(database);
1772
+ const candidate = { project, channel: fields.channel?.trim() || void 0, scope_type: scopeType, target_id: targetId };
1773
+ const conflicts = activeClaims(database, project).filter((claim2) => claimOverlaps(candidate, claim2));
1774
+ if (conflicts.length > 0 && !fields.force) {
1775
+ throw new AgentClaimError("Target already has an active overlapping agent claim.", 409, "target_already_claimed", conflicts);
1776
+ }
1777
+ if (conflicts.length > 0 && !fields.reason?.trim()) {
1778
+ throw new AgentClaimError("Overriding an active claim requires --reason.", 400, "override_reason_required", conflicts);
1779
+ }
1780
+ const timestamp = nowIso();
1781
+ for (const conflict of conflicts) {
1782
+ database.prepare(`
1783
+ update agent_claims
1784
+ set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
1785
+ where id = ? and status = 'active'
1786
+ `).run(timestamp, agentName, fields.reason || null, conflict.id);
1787
+ recordEvent(database, conflict.id, "revoked", agentName, fields.reason || "Revoked by forced claim takeover.");
1788
+ recordEvent(database, conflict.id, "conflict", agentName, `Overridden by ${agentName}.`, { new_claim_target: targetId });
1789
+ }
1790
+ const id = randomId("claim");
1791
+ const secret = randomBytes(24).toString("base64url");
1792
+ const claimToken = `${id}.${secret}`;
1793
+ const expiresAt = expiresAtFrom(timestamp, ttlSeconds);
1794
+ database.prepare(`
1795
+ insert into agent_claims (
1796
+ id, token_hash, project_id, channel, scope_type, target_id, target_title, agent_id, agent_name,
1797
+ agent_kind, thread_id, status, created_at, heartbeat_at, expires_at, metadata_json
1798
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
1799
+ `).run(
1800
+ id,
1801
+ tokenHash(claimToken),
1802
+ project,
1803
+ candidate.channel || null,
1804
+ scopeType,
1805
+ targetId,
1806
+ fields.targetTitle?.trim() || null,
1807
+ fields.agentId?.trim() || null,
1808
+ agentName,
1809
+ fields.agentKind?.trim() || "codex",
1810
+ fields.threadId?.trim() || null,
1811
+ timestamp,
1812
+ timestamp,
1813
+ expiresAt,
1814
+ metadataJson(fields.metadata)
1815
+ );
1816
+ recordEvent(database, id, "created", agentName, `Claimed ${scopeType} ${targetId}.`, { ttl_seconds: ttlSeconds });
1817
+ const claim = findClaimById(database, id);
1818
+ return { ok: true, claim, claim_token: claimToken, conflicts_revoked: conflicts.map((conflict) => conflict.id) };
1819
+ } finally {
1722
1820
  database.close();
1723
- return { ok: true, dryRun: true, edge };
1724
1821
  }
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
1822
  }
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));
1823
+ function listAgentClaims(project) {
1824
+ const database = lineageDb();
1825
+ try {
1826
+ expireActiveClaims(database);
1827
+ 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();
1828
+ return { ok: true, claims: rows.map((row) => rowToClaim(row)), fetchedAt: nowIso() };
1829
+ } finally {
1830
+ database.close();
1744
1831
  }
1745
- return edges;
1746
1832
  }
1747
- function getLineageSnapshot(project, assetId) {
1833
+ function inspectAgentClaim(claimId, project) {
1748
1834
  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
- };
1835
+ try {
1836
+ expireActiveClaims(database);
1837
+ const claim = findClaimById(database, claimId, project);
1838
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1839
+ const events = database.prepare("select * from agent_claim_events where claim_id = ? order by created_at").all(claim.id);
1840
+ return { ok: true, claim, events: events.map(eventToRow) };
1841
+ } finally {
1842
+ database.close();
1843
+ }
1798
1844
  }
1799
- function updateLineageLayout(project, fields) {
1800
- if (fields.positions.length === 0) throw new LineageError("Lineage layout requires at least one position");
1845
+ function heartbeatAgentClaim(claimToken, ttlSeconds = defaultTtlSeconds) {
1801
1846
  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) {
1847
+ try {
1848
+ expireActiveClaims(database);
1849
+ const row = findClaimRowByToken(database, claimToken);
1850
+ if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
1851
+ const claim = rowToClaim(row);
1852
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1853
+ const timestamp = nowIso();
1854
+ database.prepare("update agent_claims set heartbeat_at = ?, expires_at = ? where id = ?").run(timestamp, expiresAtFrom(timestamp, ttlSeconds), claim.id);
1855
+ recordEvent(database, claim.id, "heartbeat", claim.agent_name, "Claim heartbeat received.");
1856
+ return { ok: true, claim: findClaimById(database, claim.id) };
1857
+ } finally {
1805
1858
  database.close();
1806
- return { ok: true, dryRun: true, root_asset_id: fields.rootAssetId, positions: fields.positions };
1807
1859
  }
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
- }
1818
- database.close();
1819
- return { ok: true, message: `Saved ${fields.positions.length} lineage positions`, root_asset_id: fields.rootAssetId, positions: fields.positions };
1820
1860
  }
1821
- function getLineageNextAsset(project, rootAssetId) {
1861
+ function releaseAgentClaim(claimToken) {
1822
1862
  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.");
1832
- }
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
- };
1851
- }
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
- };
1863
+ try {
1864
+ expireActiveClaims(database);
1865
+ const row = findClaimRowByToken(database, claimToken);
1866
+ if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
1867
+ const claim = rowToClaim(row);
1868
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1869
+ const timestamp = nowIso();
1870
+ database.prepare("update agent_claims set status = 'released', released_at = ? where id = ?").run(timestamp, claim.id);
1871
+ recordEvent(database, claim.id, "released", claim.agent_name, "Claim released by token holder.");
1872
+ return { ok: true, claim: findClaimById(database, claim.id) };
1873
+ } finally {
1874
+ database.close();
1870
1875
  }
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
1876
  }
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
- };
1877
+ function releaseStaleAgentClaim(project, claimId, fields) {
1878
+ if (!fields.confirmWrite) throw new AgentClaimError("Releasing a stale agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1879
+ if (!fields.reason?.trim()) throw new AgentClaimError("Releasing a stale agent claim requires a reason.", 400, "reason_required");
1880
+ const database = lineageDb();
1881
+ try {
1882
+ expireActiveClaims(database);
1883
+ const claim = findClaimById(database, claimId, project);
1884
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1885
+ if (claim.status !== "active" || claim.derived_state !== "stale") {
1886
+ throw new AgentClaimError("Only stale active claims can be released without the claim token.", 409, "claim_not_stale", [claim]);
1887
+ }
1888
+ const timestamp = nowIso();
1889
+ 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);
1890
+ recordEvent(database, claim.id, "released", fields.actor || "human", fields.reason);
1891
+ return { ok: true, claim: findClaimById(database, claim.id) };
1892
+ } finally {
1893
+ database.close();
1894
+ }
1900
1895
  }
1901
- function updateSelectedAsset(project, fields) {
1896
+ function revokeAgentClaim(project, claimId, fields) {
1897
+ if (!fields.confirmWrite) throw new AgentClaimError("Revoking an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1898
+ if (!fields.reason?.trim()) throw new AgentClaimError("Revoking an agent claim requires a reason.", 400, "reason_required");
1902
1899
  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) {
1900
+ try {
1901
+ expireActiveClaims(database);
1902
+ const claim = findClaimById(database, claimId, project);
1903
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1904
+ const timestamp = nowIso();
1905
+ database.prepare(`
1906
+ update agent_claims set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
1907
+ where id = ?
1908
+ `).run(timestamp, fields.actor || "human", fields.reason, claim.id);
1909
+ recordEvent(database, claim.id, "revoked", fields.actor || "human", fields.reason);
1910
+ return { ok: true, claim: findClaimById(database, claim.id) };
1911
+ } finally {
1911
1912
  database.close();
1912
- return { ok: true, dryRun: true, root_asset_id: root, asset_ids: inputAssetIds, mode, clear: Boolean(fields.clear), max_selections: limit };
1913
1913
  }
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];
1927
- }
1914
+ }
1915
+ function transferAgentClaim(project, claimId, fields) {
1916
+ if (!fields.confirmWrite) throw new AgentClaimError("Transferring an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1917
+ const toAgentName = fields.toAgentName.trim();
1918
+ if (!toAgentName) throw new AgentClaimError("Transfer requires toAgentName.", 400, "agent_name_required");
1919
+ const database = lineageDb();
1920
+ try {
1921
+ expireActiveClaims(database);
1922
+ const claim = findClaimById(database, claimId, project);
1923
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1924
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1925
+ database.prepare("update agent_claims set agent_name = ? where id = ?").run(toAgentName, claim.id);
1926
+ recordEvent(database, claim.id, "transferred", fields.actor || "human", fields.reason || `Transferred claim to ${toAgentName}.`, { to_agent_name: toAgentName });
1927
+ return { ok: true, claim: findClaimById(database, claim.id) };
1928
+ } finally {
1929
+ database.close();
1928
1930
  }
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);
1942
- });
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
1931
  }
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}`);
1932
+ function validateAgentClaimForWrite(fields) {
1933
+ if (fields.dangerLevel === "danger" && !fields.confirmWrite) {
1934
+ return denied("human_confirmation_required", "Dangerous write requires explicit human confirmation.");
1935
+ }
1936
+ if (!fields.claimToken) return denied("claim_required", "Mutating agent write requires a matching claim token.");
1950
1937
  const database = lineageDb();
1951
- requireAsset2(database, project, fields.assetId);
1952
- if (!fields.confirmWrite) {
1938
+ try {
1939
+ expireActiveClaims(database);
1940
+ const row = findClaimRowByToken(database, fields.claimToken);
1941
+ if (!row) return denied("claim_token_invalid", "Unknown or invalid claim token.");
1942
+ const claim = rowToClaim(row);
1943
+ if (claim.status !== "active") return denied("claim_not_active", `Agent claim is ${claim.status}.`);
1944
+ if (new Date(claim.expires_at).getTime() <= Date.now()) return denied("claim_expired", "Agent claim has expired.");
1945
+ if (claim.project !== fields.project) return denied("claim_project_mismatch", `Claim project ${claim.project} does not match ${fields.project}.`, [claim]);
1946
+ if (fields.channel && claim.channel && claim.channel !== fields.channel) {
1947
+ return denied("claim_channel_mismatch", `Claim channel ${claim.channel} does not match ${fields.channel}.`, [claim]);
1948
+ }
1949
+ if (!scopeAllowsWrite(claim, fields.scopeType, fields.targetId, fields.writeKind)) {
1950
+ return denied("claim_scope_mismatch", `Claim does not cover ${fields.scopeType} ${fields.targetId}.`, [claim]);
1951
+ }
1952
+ recordEvent(database, claim.id, "write_allowed", claim.agent_name, `${fields.writeKind} allowed.`, {
1953
+ danger_level: fields.dangerLevel,
1954
+ target_id: fields.targetId,
1955
+ write_kind: fields.writeKind
1956
+ });
1957
+ return { ok: true, claim, warnings: [] };
1958
+ } finally {
1953
1959
  database.close();
1954
- return { ok: true, dryRun: true, asset_id: fields.assetId, review_state: fields.reviewState, notes: fields.notes };
1955
1960
  }
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
1961
  }
1967
1962
 
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 = []) {
1963
+ // src/server/lineageClaimGuards.ts
1964
+ function channelOverlaps2(left, right) {
1965
+ return !left || !right || left === right;
1966
+ }
1967
+ function hasActiveLineageWorkspaceClaim(project, targetId, channel) {
1968
+ return listAgentClaims(project).claims.some((claim) => {
1969
+ if (claim.project !== project || claim.status !== "active" || claim.derived_state === "expired") return false;
1970
+ if (claim.scope_type === "lineage_workspace") return claim.target_id === targetId;
1971
+ return claim.scope_type === "project_channel" && channelOverlaps2(channel, claim.channel);
1972
+ });
1973
+ }
1974
+ function requireLineageWorkspaceClaimForWrite(fields) {
1975
+ if (!fields.confirmWrite) return;
1976
+ const targetId = lineageWorkspaceId(fields.project, fields.rootAssetId);
1977
+ if (!fields.claimToken && !hasActiveLineageWorkspaceClaim(fields.project, targetId, fields.channel)) return;
1978
+ const validation = validateAgentClaimForWrite({
1979
+ channel: fields.channel,
1980
+ claimToken: fields.claimToken,
1981
+ confirmWrite: fields.confirmWrite,
1982
+ dangerLevel: "enforce",
1983
+ project: fields.project,
1984
+ scopeType: "lineage_workspace",
1985
+ targetId,
1986
+ writeKind: fields.writeKind
1987
+ });
1988
+ if (!validation.ok) {
1989
+ const status = validation.code === "claim_required" || validation.code === "claim_token_invalid" ? 401 : 409;
1990
+ throw new AgentClaimError(validation.message, status, validation.code, validation.conflicts);
1991
+ }
1992
+ }
1993
+
1994
+ // src/server/assetLineage.ts
1995
+ var LineageError = class extends Error {
1996
+ constructor(message, status = 400) {
1976
1997
  super(message);
1977
1998
  this.status = status;
1978
- this.code = code;
1979
- this.conflicts = conflicts;
1980
1999
  }
1981
2000
  status;
1982
- code;
1983
- conflicts;
1984
2001
  };
1985
- function isAgentClaimError(error) {
1986
- return error instanceof AgentClaimError;
1987
- }
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");
1998
- }
1999
- return seconds;
2000
- }
2001
- function randomId(prefix) {
2002
- return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString("base64url").toLowerCase()}`;
2003
- }
2004
- function tokenHash(token) {
2005
- return createHash2("sha256").update(token).digest("hex");
2006
- }
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);
2011
- }
2012
- function expiresAtFrom(timestamp, ttlSeconds) {
2013
- return new Date(new Date(timestamp).getTime() + ttlSeconds * 1e3).toISOString();
2014
- }
2015
- function metadataJson(metadata2) {
2016
- return metadata2 ? JSON.stringify(metadata2) : null;
2002
+ function isLineageError(error) {
2003
+ return error instanceof LineageError;
2017
2004
  }
2018
- function parseMetadata(value) {
2019
- if (typeof value !== "string" || !value) return void 0;
2020
- 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;
2005
+ function collectAssets(project, source) {
2006
+ const first = listAssets(project, { source, page: 1, pageSize: 100 });
2007
+ const assets = [...first.assets];
2008
+ for (let page = 2; page <= first.pagination.totalPages; page += 1) {
2009
+ assets.push(...listAssets(project, { source, page, pageSize: 100 }).assets);
2025
2010
  }
2011
+ return assets;
2026
2012
  }
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";
2034
- }
2035
- function rowToClaim(row, now = /* @__PURE__ */ new Date()) {
2036
- const heartbeatAt = String(row.heartbeat_at);
2037
- return {
2038
- 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),
2049
- 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)
2063
- };
2064
- }
2065
- function eventToRow(row) {
2066
- 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,
2071
- created_at: String(row.created_at),
2072
- metadata: parseMetadata(row.metadata_json)
2073
- };
2074
- }
2075
- function ensureProject3(database, project) {
2013
+ function upsertProject(database, project) {
2076
2014
  const timestamp = nowIso();
2077
2015
  database.prepare(`
2078
- insert into projects (id, product, created_at, updated_at)
2079
- values (?, ?, ?, ?)
2016
+ insert into projects (id, product, catalog_path, created_at, updated_at)
2017
+ values (?, ?, ?, ?, ?)
2080
2018
  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));
2019
+ `).run(project, project, join5(repoRoot, project, "assets", "catalog.json"), timestamp, timestamp);
2088
2020
  }
2089
- function expireActiveClaims(database) {
2021
+ function upsertAsset(database, project, asset) {
2090
2022
  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;
2023
+ const source = asset.source === "local" ? "local" : "catalog";
2095
2024
  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.");
2025
+ insert into assets (
2026
+ id, project_id, source, local_path, s3_key, checksum_sha256, media_type, title, status,
2027
+ channel, campaign, audience, size_bytes, content_type, created_at, updated_at, last_seen_at
2028
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2029
+ on conflict(id) do update set
2030
+ source = excluded.source, local_path = excluded.local_path, s3_key = excluded.s3_key,
2031
+ checksum_sha256 = excluded.checksum_sha256, media_type = excluded.media_type,
2032
+ title = excluded.title, status = excluded.status, channel = excluded.channel,
2033
+ campaign = excluded.campaign, audience = excluded.audience, size_bytes = excluded.size_bytes,
2034
+ content_type = excluded.content_type, updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
2035
+ `).run(
2036
+ asset.asset_id,
2037
+ project,
2038
+ source,
2039
+ asset.local?.relative_path || null,
2040
+ asset.s3?.key || null,
2041
+ asset.local?.checksum_sha256 || asset.s3?.checksum_sha256 || null,
2042
+ asset.content_type,
2043
+ asset.title,
2044
+ asset.status,
2045
+ asset.channel || null,
2046
+ asset.campaign || null,
2047
+ asset.audience || null,
2048
+ asset.local?.size_bytes || asset.s3?.size_bytes || null,
2049
+ asset.local?.content_type || asset.s3?.content_type || null,
2050
+ timestamp,
2051
+ timestamp,
2052
+ timestamp
2053
+ );
2054
+ database.prepare(`
2055
+ insert into asset_reviews (asset_id, review_state, updated_at)
2056
+ values (?, 'unreviewed', ?)
2057
+ on conflict(asset_id) do nothing
2058
+ `).run(asset.asset_id, timestamp);
2099
2059
  }
2100
- function normalizeScope(scopeType) {
2101
- if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
2102
- return scopeType;
2060
+ function indexLineageAssets(project = defaultProject) {
2061
+ const database = lineageDb();
2062
+ const catalog = collectAssets(project, "catalog");
2063
+ const local = collectAssets(project, "local");
2064
+ upsertProject(database, project);
2065
+ for (const asset of [...catalog, ...local]) upsertAsset(database, project, asset);
2066
+ database.close();
2067
+ return { catalog: catalog.length, local: local.length, total: catalog.length + local.length, database: lineageDbPath() };
2103
2068
  }
2104
- function channelOverlaps(left, right) {
2105
- return !left || !right || left === right;
2069
+ function requireAsset2(database, project, assetId) {
2070
+ const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
2071
+ if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
2106
2072
  }
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";
2073
+ function parentOf(database, project, assetId) {
2074
+ 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);
2075
+ return row?.parent_asset_id;
2112
2076
  }
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));
2077
+ function rootFor(database, project, assetId) {
2078
+ let root = assetId;
2079
+ const seen = /* @__PURE__ */ new Set();
2080
+ while (!seen.has(root)) {
2081
+ seen.add(root);
2082
+ const parent = parentOf(database, project, root);
2083
+ if (!parent) return root;
2084
+ root = parent;
2085
+ }
2086
+ return assetId;
2116
2087
  }
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;
2088
+ function explicitWorkspaceRoot(database, project, assetId) {
2089
+ const row = database.prepare(`
2090
+ select root_asset_id from lineage_workspaces
2091
+ where project_id = ? and root_asset_id = ? and status != 'archived'
2092
+ `).get(project, assetId);
2093
+ return row?.root_asset_id;
2120
2094
  }
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;
2095
+ function nearestWorkspaceRoot(database, project, assetId) {
2096
+ let current = assetId;
2097
+ const seen = /* @__PURE__ */ new Set();
2098
+ while (!seen.has(current)) {
2099
+ seen.add(current);
2100
+ const explicit = explicitWorkspaceRoot(database, project, current);
2101
+ if (explicit) return explicit;
2102
+ const parent = parentOf(database, project, current);
2103
+ if (!parent) return current;
2104
+ current = parent;
2105
+ }
2106
+ return assetId;
2126
2107
  }
2127
- function denied(code, message, conflicts = []) {
2128
- return { ok: false, code, message, conflicts };
2108
+ function assetChannel(database, project, assetId) {
2109
+ const row = database.prepare("select channel from assets where project_id = ? and id = ?").get(project, assetId);
2110
+ return row?.channel;
2129
2111
  }
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;
2112
+ function lineageWriteClaimContext(database, project, assetId) {
2113
+ return {
2114
+ channel: assetChannel(database, project, assetId),
2115
+ rootAssetId: nearestWorkspaceRoot(database, project, assetId)
2116
+ };
2136
2117
  }
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;
2118
+ function getLineageWriteClaimContext(project, assetId) {
2146
2119
  const database = lineageDb();
2147
2120
  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),
2180
- 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) };
2121
+ requireAsset2(database, project, assetId);
2122
+ return lineageWriteClaimContext(database, project, assetId);
2197
2123
  } finally {
2198
2124
  database.close();
2199
2125
  }
2200
2126
  }
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();
2127
+ function latestSelectedRoot(database, project) {
2128
+ const row = database.prepare("select root_asset_id from asset_selections where project_id = ? order by selected_at desc limit 1").get(project);
2129
+ return row?.root_asset_id;
2130
+ }
2131
+ function resolveRoot(database, project, rootAssetId) {
2132
+ if (rootAssetId) {
2133
+ requireAsset2(database, project, rootAssetId);
2134
+ return rootAssetId;
2209
2135
  }
2136
+ const root = activeLineageWorkspaceRoot(project) || latestSelectedRoot(database, project);
2137
+ if (!root) throw new LineageError("Lineage command requires --root unless a project selection exists");
2138
+ requireAsset2(database, project, root);
2139
+ return root;
2210
2140
  }
2211
- function inspectAgentClaim(claimId, project) {
2141
+ function edgeId(project, parent, child) {
2142
+ return `${project}:${parent}:derived_from:${child}`;
2143
+ }
2144
+ function canPreviewLocally(mediaType, localPath) {
2145
+ return Boolean(localPath && ["image", "video", "gif"].includes(mediaType));
2146
+ }
2147
+ function localPreviewUrl(project, localPath) {
2148
+ if (!localPath) return void 0;
2149
+ const params = new URLSearchParams({ project, path: localPath });
2150
+ return `/api/assets/local-preview?${params.toString()}`;
2151
+ }
2152
+ function linkLineageAssets(project, fields) {
2212
2153
  const database = lineageDb();
2154
+ requireAsset2(database, project, fields.parentAssetId);
2155
+ requireAsset2(database, project, fields.childAssetId);
2156
+ if (fields.parentAssetId === fields.childAssetId) throw new LineageError("Lineage link cannot point to itself");
2157
+ const claimContext = lineageWriteClaimContext(database, project, fields.parentAssetId);
2213
2158
  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) };
2219
- } finally {
2159
+ requireLineageWorkspaceClaimForWrite({
2160
+ channel: claimContext.channel,
2161
+ claimToken: fields.claimToken,
2162
+ confirmWrite: fields.confirmWrite,
2163
+ project,
2164
+ rootAssetId: claimContext.rootAssetId,
2165
+ writeKind: "lineage_link"
2166
+ });
2167
+ } catch (error) {
2220
2168
  database.close();
2169
+ throw error;
2221
2170
  }
2222
- }
2223
- function heartbeatAgentClaim(claimToken, ttlSeconds = defaultTtlSeconds) {
2224
- const database = lineageDb();
2225
- 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) };
2235
- } finally {
2171
+ const edge = {
2172
+ id: edgeId(project, fields.parentAssetId, fields.childAssetId),
2173
+ parent_asset_id: fields.parentAssetId,
2174
+ child_asset_id: fields.childAssetId,
2175
+ relation_type: "derived_from",
2176
+ created_at: nowIso()
2177
+ };
2178
+ if (!fields.confirmWrite) {
2236
2179
  database.close();
2180
+ return { ok: true, dryRun: true, edge };
2237
2181
  }
2182
+ database.prepare(`
2183
+ insert into asset_edges (id, project_id, parent_asset_id, child_asset_id, relation_type, created_at)
2184
+ values (?, ?, ?, ?, 'derived_from', ?)
2185
+ on conflict(project_id, parent_asset_id, child_asset_id, relation_type) do nothing
2186
+ `).run(edge.id, project, edge.parent_asset_id, edge.child_asset_id, edge.created_at);
2187
+ database.close();
2188
+ return { ok: true, message: `Linked ${edge.child_asset_id} from ${edge.parent_asset_id}`, edge };
2238
2189
  }
2239
- function releaseAgentClaim(claimToken) {
2240
- const database = lineageDb();
2241
- 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");
2247
- 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) };
2251
- } finally {
2252
- database.close();
2190
+ function descendants(database, project, root) {
2191
+ const edges = [];
2192
+ const queue = [root];
2193
+ const seen = /* @__PURE__ */ new Set();
2194
+ while (queue.length > 0) {
2195
+ const parent = queue.shift();
2196
+ if (seen.has(parent)) continue;
2197
+ seen.add(parent);
2198
+ 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);
2199
+ edges.push(...rows);
2200
+ queue.push(...rows.map((row) => row.child_asset_id));
2253
2201
  }
2202
+ return edges;
2203
+ }
2204
+ function getLineageSnapshot(project, assetId) {
2205
+ const database = lineageDb();
2206
+ requireAsset2(database, project, assetId);
2207
+ const root = explicitWorkspaceRoot(database, project, assetId) || rootFor(database, project, assetId);
2208
+ const edges = descendants(database, project, root);
2209
+ const ids = [.../* @__PURE__ */ new Set([root, ...edges.flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id])])];
2210
+ const placeholders2 = ids.map(() => "?").join(",");
2211
+ const rows = database.prepare(`
2212
+ select a.id asset_id, a.project_id project, a.source, a.title, a.media_type, a.status, a.channel, a.campaign,
2213
+ a.local_path, a.s3_key, a.checksum_sha256, coalesce(r.review_state, 'unreviewed') review_state,
2214
+ r.notes review_notes, l.x layout_x, l.y layout_y
2215
+ from assets a left join asset_reviews r on r.asset_id = a.id
2216
+ left join asset_layouts l on l.project_id = a.project_id and l.root_asset_id = ? and l.asset_id = a.id
2217
+ where a.project_id = ? and a.id in (${placeholders2})
2218
+ `).all(root, project, ...ids);
2219
+ const selected = selectedRows(database, project, root);
2220
+ const childIds = new Set(edges.map((edge) => edge.parent_asset_id));
2221
+ const selectedIds = new Set(selected.map((row) => row.asset_id));
2222
+ const selections = selected.map((row) => ({
2223
+ asset_id: row.asset_id,
2224
+ notes: row.notes || void 0,
2225
+ position: Number(row.position || 0),
2226
+ selected_at: row.selected_at
2227
+ }));
2228
+ const selection = selections[0] || null;
2229
+ const nodes = rows.map((row) => {
2230
+ const position = typeof row.layout_x === "number" && typeof row.layout_y === "number" ? { x: row.layout_x, y: row.layout_y } : void 0;
2231
+ const { layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
2232
+ const nodeSelection = selections.find((item) => item.asset_id === row.asset_id);
2233
+ return {
2234
+ ...node,
2235
+ is_latest: !childIds.has(row.asset_id),
2236
+ position,
2237
+ preview_url: canPreviewLocally(row.media_type, row.local_path) ? localPreviewUrl(project, row.local_path) : void 0,
2238
+ selection_note: nodeSelection?.notes,
2239
+ user_selected: selectedIds.has(row.asset_id)
2240
+ };
2241
+ });
2242
+ database.close();
2243
+ return {
2244
+ project,
2245
+ root_asset_id: root,
2246
+ active_asset_id: assetId,
2247
+ selected: selections.map((row) => row.asset_id),
2248
+ selection,
2249
+ selections,
2250
+ latest: nodes.filter((node) => node.is_latest).map((node) => node.asset_id),
2251
+ nodes,
2252
+ edges,
2253
+ fetchedAt: nowIso()
2254
+ };
2254
2255
  }
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");
2256
+ function updateLineageLayout(project, fields) {
2257
+ if (fields.positions.length === 0) throw new LineageError("Lineage layout requires at least one position");
2258
2258
  const database = lineageDb();
2259
+ requireAsset2(database, project, fields.rootAssetId);
2260
+ for (const position of fields.positions) requireAsset2(database, project, position.assetId);
2259
2261
  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
- }
2266
- 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) };
2270
- } finally {
2262
+ requireLineageWorkspaceClaimForWrite({
2263
+ channel: assetChannel(database, project, fields.rootAssetId),
2264
+ claimToken: fields.claimToken,
2265
+ confirmWrite: fields.confirmWrite,
2266
+ project,
2267
+ rootAssetId: fields.rootAssetId,
2268
+ writeKind: "lineage_layout"
2269
+ });
2270
+ } catch (error) {
2271
+ database.close();
2272
+ throw error;
2273
+ }
2274
+ if (!fields.confirmWrite) {
2271
2275
  database.close();
2276
+ return { ok: true, dryRun: true, root_asset_id: fields.rootAssetId, positions: fields.positions };
2277
+ }
2278
+ const timestamp = nowIso();
2279
+ const statement = database.prepare(`
2280
+ insert into asset_layouts (id, project_id, root_asset_id, asset_id, x, y, updated_at)
2281
+ values (?, ?, ?, ?, ?, ?, ?)
2282
+ on conflict(project_id, root_asset_id, asset_id) do update set
2283
+ x = excluded.x, y = excluded.y, updated_at = excluded.updated_at
2284
+ `);
2285
+ for (const position of fields.positions) {
2286
+ statement.run(`${project}:${fields.rootAssetId}:layout:${position.assetId}`, project, fields.rootAssetId, position.assetId, position.x, position.y, timestamp);
2272
2287
  }
2288
+ database.close();
2289
+ return { ok: true, message: `Saved ${fields.positions.length} lineage positions`, root_asset_id: fields.rootAssetId, positions: fields.positions };
2273
2290
  }
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");
2291
+ function getLineageNextAsset(project, rootAssetId) {
2277
2292
  const database = lineageDb();
2278
- 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");
2282
- const timestamp = nowIso();
2283
- database.prepare(`
2284
- update agent_claims set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
2285
- 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) };
2289
- } finally {
2290
- database.close();
2293
+ const root = resolveRoot(database, project, rootAssetId);
2294
+ database.close();
2295
+ const snapshot = getLineageSnapshot(project, root);
2296
+ const selectedNodes = snapshot.selected.map((assetId) => snapshot.nodes.find((node) => node.asset_id === assetId)).filter((node) => Boolean(node));
2297
+ const latestNodes = snapshot.nodes.filter((node) => snapshot.latest.includes(node.asset_id));
2298
+ const warnings = [];
2299
+ for (const selectedNode of selectedNodes) {
2300
+ if (selectedNode.is_latest) continue;
2301
+ warnings.push("Selected asset is not a latest leaf; agents should treat this as an intentional branch choice.");
2302
+ }
2303
+ if (selectedNodes.length > 0) {
2304
+ return {
2305
+ project,
2306
+ root_asset_id: snapshot.root_asset_id,
2307
+ strategy: "selected",
2308
+ selection_mode: selectedNodes.length > 1 ? "multiple" : "single",
2309
+ recommended_action: "evolve_variations",
2310
+ reason: "user_selected",
2311
+ next_asset: selectedNodes[0],
2312
+ next_assets: selectedNodes,
2313
+ latest: snapshot.latest,
2314
+ selected: snapshot.selected,
2315
+ selection: snapshot.selection,
2316
+ selections: snapshot.selections,
2317
+ candidates: latestNodes,
2318
+ warnings,
2319
+ fetchedAt: nowIso()
2320
+ };
2321
+ }
2322
+ if (latestNodes.length === 1) {
2323
+ return {
2324
+ project,
2325
+ root_asset_id: snapshot.root_asset_id,
2326
+ strategy: "single_latest",
2327
+ selection_mode: "fallback",
2328
+ recommended_action: "evolve_variations",
2329
+ reason: "single_latest_fallback",
2330
+ next_asset: latestNodes[0],
2331
+ next_assets: [latestNodes[0]],
2332
+ latest: snapshot.latest,
2333
+ selected: snapshot.selected,
2334
+ selection: snapshot.selection,
2335
+ selections: snapshot.selections,
2336
+ candidates: latestNodes,
2337
+ warnings,
2338
+ fetchedAt: nowIso()
2339
+ };
2291
2340
  }
2341
+ return {
2342
+ project,
2343
+ root_asset_id: snapshot.root_asset_id,
2344
+ strategy: latestNodes.length > 1 ? "ambiguous_latest" : "empty",
2345
+ selection_mode: "none",
2346
+ recommended_action: latestNodes.length > 1 ? "choose_next_base" : "none",
2347
+ reason: latestNodes.length > 1 ? "multiple_latest_no_selection" : "no_lineage_candidates",
2348
+ next_asset: null,
2349
+ next_assets: [],
2350
+ latest: snapshot.latest,
2351
+ selected: snapshot.selected,
2352
+ selection: snapshot.selection,
2353
+ selections: snapshot.selections,
2354
+ candidates: latestNodes,
2355
+ warnings,
2356
+ fetchedAt: nowIso()
2357
+ };
2292
2358
  }
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");
2359
+ function getLineageChildren(project, parentAssetId) {
2360
+ const snapshot = getLineageSnapshot(project, parentAssetId);
2361
+ const edges = snapshot.edges.filter((edge) => edge.parent_asset_id === parentAssetId);
2362
+ const childIds = new Set(edges.map((edge) => edge.child_asset_id));
2363
+ return {
2364
+ project,
2365
+ parent_asset_id: parentAssetId,
2366
+ children: snapshot.nodes.filter((node) => childIds.has(node.asset_id)),
2367
+ edges,
2368
+ fetchedAt: nowIso()
2369
+ };
2370
+ }
2371
+ function updateSelectedAsset(project, fields) {
2297
2372
  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 {
2373
+ const inputAssetIds = normalizeSelectionInput(fields);
2374
+ const root = fields.rootAssetId || (inputAssetIds[0] ? rootFor(database, project, inputAssetIds[0]) : "");
2375
+ if (!root) throw new LineageError("Selection requires rootAssetId or assetId");
2376
+ requireAsset2(database, project, root);
2377
+ for (const assetId of inputAssetIds) requireAsset2(database, project, assetId);
2378
+ const mode = fields.mode || "replace";
2379
+ const limit = fields.maxSelections || LINEAGE_NEXT_VARIATION_LIMIT;
2380
+ if (!fields.confirmWrite) {
2307
2381
  database.close();
2382
+ return { ok: true, dryRun: true, root_asset_id: root, asset_ids: inputAssetIds, mode, clear: Boolean(fields.clear), max_selections: limit };
2308
2383
  }
2309
- }
2310
- function validateAgentClaimForWrite(fields) {
2311
- if (fields.dangerLevel === "danger" && !fields.confirmWrite) {
2312
- return denied("human_confirmation_required", "Dangerous write requires explicit human confirmation.");
2384
+ const current = selectedRows(database, project, root);
2385
+ let nextIds = current.map((row) => row.asset_id);
2386
+ if (fields.clear) {
2387
+ nextIds = [];
2388
+ } else if (mode === "replace") {
2389
+ nextIds = inputAssetIds;
2390
+ } else if (mode === "add") {
2391
+ nextIds = [...nextIds, ...inputAssetIds];
2392
+ } else if (mode === "remove") {
2393
+ nextIds = nextIds.filter((assetId) => !inputAssetIds.includes(assetId));
2394
+ } else if (mode === "toggle") {
2395
+ for (const assetId of inputAssetIds) {
2396
+ nextIds = nextIds.includes(assetId) ? nextIds.filter((id) => id !== assetId) : [...nextIds, assetId];
2397
+ }
2313
2398
  }
2314
- if (!fields.claimToken) return denied("claim_required", "Mutating agent write requires a matching claim token.");
2399
+ nextIds = [...new Set(nextIds)];
2400
+ if (!fields.clear && inputAssetIds.length === 0) throw new LineageError("Selection set requires assetId or assetIds");
2401
+ if (nextIds.length > limit) throw new LineageError(`Select at most ${limit} assets for next variation`);
2402
+ database.prepare("delete from asset_selections where project_id = ? and root_asset_id = ?").run(project, root);
2403
+ const timestamp = nowIso();
2404
+ const insert = database.prepare(`
2405
+ insert into asset_selections (id, project_id, root_asset_id, asset_id, position, notes, selected_at)
2406
+ values (?, ?, ?, ?, ?, ?, ?)
2407
+ `);
2408
+ nextIds.forEach((assetId, position) => {
2409
+ const existing = current.find((row) => row.asset_id === assetId);
2410
+ const notes = inputAssetIds.includes(assetId) ? fields.notes || existing?.notes : existing?.notes;
2411
+ insert.run(selectionId(project, root, assetId), project, root, assetId, position, notes || null, timestamp);
2412
+ });
2413
+ database.close();
2414
+ const message = nextIds.length === 0 ? `Cleared selected assets for ${root}` : `Selected ${nextIds.length} asset${nextIds.length === 1 ? "" : "s"} for ${root}`;
2415
+ return { ok: true, message, root_asset_id: root, asset_id: nextIds[0] || null, asset_ids: nextIds, mode };
2416
+ }
2417
+ function updateAssetReview(project, fields) {
2418
+ const allowed = /* @__PURE__ */ new Set(["unreviewed", "approved", "needs_revision", "rejected", "ignored"]);
2419
+ if (!allowed.has(fields.reviewState)) throw new LineageError(`Unsupported review state: ${fields.reviewState}`);
2315
2420
  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 {
2421
+ requireAsset2(database, project, fields.assetId);
2422
+ if (!fields.confirmWrite) {
2337
2423
  database.close();
2424
+ return { ok: true, dryRun: true, asset_id: fields.assetId, review_state: fields.reviewState, notes: fields.notes };
2338
2425
  }
2426
+ const timestamp = nowIso();
2427
+ database.prepare(`
2428
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
2429
+ values (?, ?, ?, ?, ?, ?)
2430
+ on conflict(asset_id) do update set
2431
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
2432
+ ignored_at = excluded.ignored_at, notes = excluded.notes, updated_at = excluded.updated_at
2433
+ `).run(fields.assetId, fields.reviewState, timestamp, fields.reviewState === "ignored" ? timestamp : null, fields.notes || null, timestamp);
2434
+ database.close();
2435
+ return { ok: true, message: `Marked ${fields.assetId} ${fields.reviewState}`, asset_id: fields.assetId, review_state: fields.reviewState };
2339
2436
  }
2340
2437
 
2341
2438
  // src/server/assetLineageHandoff.ts
@@ -2396,14 +2493,15 @@ function linkSelectedLineageChild(project, fields) {
2396
2493
  const next = getLineageNextAsset(project, fields.rootAssetId);
2397
2494
  if (!next.next_asset) throw new LineageError("Cannot link child until a next base is selected or unambiguous");
2398
2495
  if (fields.confirmWrite) {
2496
+ const claimContext = getLineageWriteClaimContext(project, next.next_asset.asset_id);
2399
2497
  const validation = validateAgentClaimForWrite({
2400
- channel: next.next_asset.channel,
2498
+ channel: claimContext.channel,
2401
2499
  claimToken: fields.claimToken,
2402
2500
  confirmWrite: fields.confirmWrite,
2403
2501
  dangerLevel: "enforce",
2404
2502
  project,
2405
2503
  scopeType: "lineage_workspace",
2406
- targetId: lineageWorkspaceId(project, next.root_asset_id),
2504
+ targetId: lineageWorkspaceId(project, claimContext.rootAssetId),
2407
2505
  writeKind: "link_child"
2408
2506
  });
2409
2507
  if (!validation.ok) throw new AgentClaimError(validation.message, validation.code === "claim_required" ? 401 : 403, validation.code, validation.conflicts);
@@ -2429,6 +2527,10 @@ function requireAsset3(database, project, assetId) {
2429
2527
  const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
2430
2528
  if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
2431
2529
  }
2530
+ function assetChannel2(database, project, assetId) {
2531
+ const row = database.prepare("select channel from assets where project_id = ? and id = ?").get(project, assetId);
2532
+ return row?.channel;
2533
+ }
2432
2534
  function parentOf2(database, project, assetId) {
2433
2535
  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
2536
  return row?.parent_asset_id;
@@ -2475,6 +2577,19 @@ function removeLineageNode(project, fields) {
2475
2577
  database.close();
2476
2578
  throw new LineageError(`Asset ${fields.assetId} is not in lineage rooted at ${root}`, 404);
2477
2579
  }
2580
+ try {
2581
+ requireLineageWorkspaceClaimForWrite({
2582
+ channel: assetChannel2(database, project, root),
2583
+ claimToken: fields.claimToken,
2584
+ confirmWrite: fields.confirmWrite,
2585
+ project,
2586
+ rootAssetId: root,
2587
+ writeKind: "lineage_remove_node"
2588
+ });
2589
+ } catch (error) {
2590
+ database.close();
2591
+ throw error;
2592
+ }
2478
2593
  const parentEdges = snapshot.edges.filter((edge) => edge.child_asset_id === fields.assetId);
2479
2594
  const childEdges = snapshot.edges.filter((edge) => edge.parent_asset_id === fields.assetId);
2480
2595
  const removedIds = [...parentEdges, ...childEdges].map((edge) => edge.id);
@@ -4215,7 +4330,7 @@ function updateContentPost(project, fields) {
4215
4330
  const timestamp = nowIso();
4216
4331
  try {
4217
4332
  const current = findContentPost(database, project, postId);
4218
- if (fields.phase) requireContentPostClaim(project, current, fields.claimToken, "content_post_phase");
4333
+ requireContentPostClaim(project, current, fields.claimToken, fields.phase ? "content_post_phase" : "content_post_update");
4219
4334
  if (fields.batchId) {
4220
4335
  const batch = database.prepare("select id from content_batches where project_id = ? and id = ?").get(project, fields.batchId);
4221
4336
  if (!batch) throw new ContentBatchError(`Unknown content batch: ${fields.batchId}`, 404);
@@ -4389,7 +4504,8 @@ function setContentTarget(project, fields) {
4389
4504
  const database = lineageDb();
4390
4505
  const timestamp = nowIso();
4391
4506
  try {
4392
- findPost(database, project, postId);
4507
+ const post = findPost(database, project, postId);
4508
+ requireContentPostClaim(project, post, fields.claimToken, "content_target_set");
4393
4509
  database.prepare(`
4394
4510
  insert into content_targets (project_id, post_id, notes, selected_at, updated_at)
4395
4511
  values (?, ?, ?, ?, ?)
@@ -4402,10 +4518,19 @@ function setContentTarget(project, fields) {
4402
4518
  }
4403
4519
  return { ok: true, message: `Selected content target ${postId}`, ...getContentTarget(project) };
4404
4520
  }
4405
- function clearContentTarget(project, confirmWrite) {
4521
+ function clearContentTarget(project, confirmWrite, claimToken) {
4406
4522
  if (!confirmWrite) return { ok: true, dryRun: true, message: "Would clear selected content target" };
4407
4523
  const database = lineageDb();
4408
4524
  try {
4525
+ const row = database.prepare("select post_id from content_targets where project_id = ?").get(project);
4526
+ if (row?.post_id) {
4527
+ try {
4528
+ const post = findPost(database, project, String(row.post_id));
4529
+ requireContentPostClaim(project, post, claimToken, "content_target_clear");
4530
+ } catch (error) {
4531
+ if (!(error instanceof ContentBatchError && error.status === 404)) throw error;
4532
+ }
4533
+ }
4409
4534
  database.prepare("delete from content_targets where project_id = ?").run(project);
4410
4535
  } finally {
4411
4536
  database.close();
@@ -4873,13 +4998,14 @@ function contentBatchRouter(projectFrom2) {
4873
4998
  });
4874
4999
  router.post("/target", (req, res) => {
4875
5000
  res.json(setContentTarget(projectFrom2(bodyProjectShape(req)), {
5001
+ claimToken: claimTokenFromRequest2(req),
4876
5002
  confirmWrite: boolBody2(req, "confirmWrite"),
4877
5003
  notes: stringBody2(req, "notes"),
4878
5004
  postId: stringBody2(req, "postId") || ""
4879
5005
  }));
4880
5006
  });
4881
5007
  router.post("/target/clear", (req, res) => {
4882
- res.json(clearContentTarget(projectFrom2(bodyProjectShape(req)), boolBody2(req, "confirmWrite")));
5008
+ res.json(clearContentTarget(projectFrom2(bodyProjectShape(req)), boolBody2(req, "confirmWrite"), claimTokenFromRequest2(req)));
4883
5009
  });
4884
5010
  router.get("/batches/:batchId", (req, res) => {
4885
5011
  res.json(getContentBatch(projectFrom2(req), req.params.batchId));
@@ -5815,13 +5941,14 @@ app.post(
5815
5941
  linkLineageAssets(projectFrom(req), {
5816
5942
  parentAssetId: String(req.body.parentAssetId || ""),
5817
5943
  childAssetId: String(req.body.childAssetId || ""),
5818
- confirmWrite: req.body.confirmWrite === true
5944
+ confirmWrite: req.body.confirmWrite === true,
5945
+ claimToken: claimTokenFromRequest(req)
5819
5946
  })
5820
5947
  );
5821
5948
  })
5822
5949
  );
5823
5950
  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 }));
5951
+ 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
5952
  }));
5826
5953
  app.post(
5827
5954
  "/api/lineage/layout",
@@ -5835,7 +5962,8 @@ app.post(
5835
5962
  x: Number(position.x),
5836
5963
  y: Number(position.y)
5837
5964
  })),
5838
- confirmWrite: req.body.confirmWrite === true
5965
+ confirmWrite: req.body.confirmWrite === true,
5966
+ claimToken: claimTokenFromRequest(req)
5839
5967
  })
5840
5968
  );
5841
5969
  })