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