@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/CHANGELOG.md +7 -0
- package/README.md +8 -1
- package/dist/cli/lineage-dev.js +123 -3
- package/dist/cli/lineage-dev.js.map +4 -4
- package/dist/cli/lineage.js +123 -3
- package/dist/cli/lineage.js.map +4 -4
- package/dist/server.js +782 -654
- package/dist/server.js.map +4 -4
- package/dist/web/assets/index-C54JeMzP.js +22 -0
- package/dist/web/assets/index-DkLQJqxa.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +2 -1
- package/dist/web/assets/index-DI7dn5M6.css +0 -1
- package/dist/web/assets/index-dJy71x2a.js +0 -21
package/dist/server.js
CHANGED
|
@@ -1587,755 +1587,852 @@ function activeLineageWorkspaceRoot(project) {
|
|
|
1587
1587
|
}
|
|
1588
1588
|
}
|
|
1589
1589
|
|
|
1590
|
-
// src/server/
|
|
1591
|
-
|
|
1592
|
-
|
|
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
|
|
1599
|
-
return error instanceof
|
|
1607
|
+
function isAgentClaimError(error) {
|
|
1608
|
+
return error instanceof AgentClaimError;
|
|
1600
1609
|
}
|
|
1601
|
-
function
|
|
1602
|
-
|
|
1603
|
-
const
|
|
1604
|
-
|
|
1605
|
-
|
|
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
|
|
1621
|
+
return seconds;
|
|
1608
1622
|
}
|
|
1609
|
-
function
|
|
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,
|
|
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,
|
|
1703
|
+
`).run(project, project, timestamp, timestamp);
|
|
1616
1704
|
}
|
|
1617
|
-
function
|
|
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
|
|
1652
|
-
values (?,
|
|
1653
|
-
|
|
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
|
|
1657
|
-
const
|
|
1658
|
-
const
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
database.
|
|
1663
|
-
|
|
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
|
|
1666
|
-
|
|
1667
|
-
|
|
1722
|
+
function normalizeScope(scopeType) {
|
|
1723
|
+
if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
|
|
1724
|
+
return scopeType;
|
|
1668
1725
|
}
|
|
1669
|
-
function
|
|
1670
|
-
|
|
1671
|
-
return row?.parent_asset_id;
|
|
1726
|
+
function channelOverlaps(left, right) {
|
|
1727
|
+
return !left || !right || left === right;
|
|
1672
1728
|
}
|
|
1673
|
-
function
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
|
1685
|
-
const
|
|
1686
|
-
return row
|
|
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
|
|
1689
|
-
|
|
1690
|
-
|
|
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
|
|
1699
|
-
|
|
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
|
|
1702
|
-
return
|
|
1749
|
+
function denied(code, message, conflicts = []) {
|
|
1750
|
+
return { ok: false, code, message, conflicts };
|
|
1703
1751
|
}
|
|
1704
|
-
function
|
|
1705
|
-
if (
|
|
1706
|
-
|
|
1707
|
-
return
|
|
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
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
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
|
|
1833
|
+
function inspectAgentClaim(claimId, project) {
|
|
1748
1834
|
const database = lineageDb();
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
|
1861
|
+
function releaseAgentClaim(claimToken) {
|
|
1822
1862
|
const database = lineageDb();
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
const
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
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
|
|
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
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
-
|
|
1915
|
-
|
|
1916
|
-
if (fields.
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
|
1948
|
-
|
|
1949
|
-
|
|
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
|
-
|
|
1952
|
-
|
|
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/
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
|
1986
|
-
return error instanceof
|
|
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
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|
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
|
|
2021
|
+
function upsertAsset(database, project, asset) {
|
|
2090
2022
|
const timestamp = nowIso();
|
|
2091
|
-
const
|
|
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
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
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
|
|
2101
|
-
|
|
2102
|
-
|
|
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
|
|
2105
|
-
|
|
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
|
|
2108
|
-
|
|
2109
|
-
|
|
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
|
|
2114
|
-
|
|
2115
|
-
|
|
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
|
|
2118
|
-
const row =
|
|
2119
|
-
|
|
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
|
|
2122
|
-
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2125
|
-
|
|
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
|
|
2128
|
-
|
|
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
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
|
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
|
-
|
|
2149
|
-
|
|
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
|
|
2202
|
-
const database =
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
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
|
|
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
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
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
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
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
|
|
2240
|
-
const
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
const
|
|
2248
|
-
|
|
2249
|
-
|
|
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
|
|
2256
|
-
if (
|
|
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
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
|
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
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
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
|
|
2294
|
-
|
|
2295
|
-
const
|
|
2296
|
-
|
|
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
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
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
|
-
|
|
2311
|
-
if (fields.
|
|
2312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2317
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
})
|