@mean-weasel/lineage 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -328,6 +328,10 @@ function lineageDb() {
328
328
  create index if not exists assets_project_source_seen on assets(project_id, source, last_seen_at);
329
329
  create index if not exists assets_project_checksum on assets(project_id, checksum_sha256);
330
330
  create index if not exists assets_project_channel_campaign on assets(project_id, channel, campaign);
331
+ create table if not exists lineage_schema_migrations (
332
+ key text primary key,
333
+ applied_at text not null
334
+ );
331
335
  create table if not exists asset_edges (
332
336
  id text primary key,
333
337
  project_id text not null references projects(id),
@@ -339,6 +343,44 @@ function lineageDb() {
339
343
  );
340
344
  create index if not exists edges_parent on asset_edges(project_id, parent_asset_id);
341
345
  create index if not exists edges_child on asset_edges(project_id, child_asset_id);
346
+ create table if not exists asset_attempts (
347
+ id text primary key,
348
+ project_id text not null references projects(id),
349
+ node_asset_id text not null references assets(id),
350
+ asset_id text not null references assets(id),
351
+ attempt_index integer not null check (attempt_index > 0),
352
+ source text not null check (source in ('initial', 'generated_child', 'reroll')),
353
+ prompt text,
354
+ generation_job_id text,
355
+ file_path text,
356
+ checksum_sha256 text,
357
+ created_at text not null,
358
+ promoted_at text,
359
+ is_current integer not null check (is_current in (0, 1)),
360
+ unique(project_id, node_asset_id, attempt_index),
361
+ unique(project_id, node_asset_id, asset_id, source)
362
+ );
363
+ create unique index if not exists asset_attempts_one_current
364
+ on asset_attempts(project_id, node_asset_id)
365
+ where is_current = 1;
366
+ create index if not exists asset_attempts_node_created
367
+ on asset_attempts(project_id, node_asset_id, created_at);
368
+ create table if not exists asset_reroll_requests (
369
+ id text primary key,
370
+ project_id text not null references projects(id),
371
+ root_asset_id text not null references assets(id),
372
+ node_asset_id text not null references assets(id),
373
+ status text not null check (status in ('pending', 'resolved', 'cancelled')),
374
+ requested_by text not null check (requested_by in ('human', 'agent', 'system')),
375
+ notes text,
376
+ created_at text not null,
377
+ resolved_at text
378
+ );
379
+ create unique index if not exists asset_reroll_requests_one_pending
380
+ on asset_reroll_requests(project_id, root_asset_id, node_asset_id)
381
+ where status = 'pending';
382
+ create index if not exists asset_reroll_requests_root_status
383
+ on asset_reroll_requests(project_id, root_asset_id, status, created_at);
342
384
  create table if not exists asset_reviews (
343
385
  asset_id text primary key references assets(id),
344
386
  review_state text not null check (review_state in ('unreviewed', 'approved', 'needs_revision', 'rejected', 'ignored')),
@@ -543,7 +585,7 @@ function lineageDb() {
543
585
  project_id text not null references projects(id),
544
586
  provider text not null default 'codex-handoff',
545
587
  adapter_version text not null,
546
- source_mode text not null check (source_mode in ('lineage_selection')),
588
+ source_mode text not null check (source_mode in ('lineage_selection', 'lineage_reroll')),
547
589
  root_asset_id text not null references assets(id),
548
590
  prompt text not null,
549
591
  expected_output_count integer not null check (expected_output_count > 0),
@@ -561,7 +603,7 @@ function lineageDb() {
561
603
  project_id text not null references projects(id),
562
604
  asset_id text not null references assets(id),
563
605
  root_asset_id text not null references assets(id),
564
- role text not null check (role in ('lineage_next_base', 'reference')),
606
+ role text not null check (role in ('lineage_next_base', 'reference', 'reroll_target')),
565
607
  position integer not null,
566
608
  selection_strategy text not null,
567
609
  selection_snapshot_json text not null,
@@ -595,12 +637,49 @@ function lineageDb() {
595
637
  );
596
638
  create index if not exists generation_job_receipts_job on generation_job_receipts(job_id, created_at);
597
639
  create table if not exists adapter_settings (project_id text not null references projects(id), adapter_type text not null check (adapter_type in ('cloud', 'scheduler', 'image_generator')), provider text not null, enabled integer not null check (enabled in (0, 1)), secret_ref text, safe_config_json text not null, created_at text not null, updated_at text not null, primary key(project_id, adapter_type, provider)); create index if not exists adapter_settings_project_type on adapter_settings(project_id, adapter_type);
640
+ create table if not exists lineage_tasks (
641
+ id text primary key,
642
+ project_id text not null references projects(id),
643
+ root_asset_id text not null references assets(id),
644
+ target_asset_id text not null references assets(id),
645
+ task_type text not null check (task_type in ('iterate', 'reroll')),
646
+ status text not null check (status in ('pending', 'claimed', 'in_progress', 'resolved', 'cancelled')),
647
+ instructions text,
648
+ created_by text not null check (created_by in ('human', 'agent', 'system')),
649
+ created_at text not null,
650
+ updated_at text not null,
651
+ claimed_at text,
652
+ started_at text,
653
+ resolved_at text,
654
+ cancelled_at text,
655
+ resolved_generation_job_id text,
656
+ resolved_asset_id text references assets(id),
657
+ metadata_json text
658
+ );
659
+ create unique index if not exists lineage_tasks_one_open
660
+ on lineage_tasks(project_id, root_asset_id, target_asset_id, task_type)
661
+ where status in ('pending', 'claimed', 'in_progress');
662
+ create index if not exists lineage_tasks_root_status
663
+ on lineage_tasks(project_id, root_asset_id, status, updated_at);
664
+ create index if not exists lineage_tasks_target
665
+ on lineage_tasks(project_id, root_asset_id, target_asset_id, task_type, status);
666
+ create table if not exists lineage_task_events (
667
+ id text primary key,
668
+ task_id text not null references lineage_tasks(id) on delete cascade,
669
+ event_type text not null,
670
+ actor text,
671
+ message text,
672
+ created_at text not null,
673
+ metadata_json text
674
+ );
675
+ create index if not exists lineage_task_events_task_created
676
+ on lineage_task_events(task_id, created_at);
598
677
  create table if not exists agent_claims (
599
678
  id text primary key,
600
679
  token_hash text not null,
601
680
  project_id text not null references projects(id),
602
681
  channel text,
603
- scope_type text not null check (scope_type in ('lineage_workspace', 'content_post', 'content_queue_lane', 'selection_set', 'project_channel')),
682
+ scope_type text not null check (scope_type in ('lineage_workspace', 'lineage_task', 'content_post', 'content_queue_lane', 'selection_set', 'project_channel')),
604
683
  target_id text not null,
605
684
  target_title text,
606
685
  agent_id text,
@@ -634,13 +713,76 @@ function lineageDb() {
634
713
  migrateAssetSelections(database);
635
714
  dropLegacyAssetSelectionRootUnique(database);
636
715
  ensureColumn(database, "asset_selections", "notes", "text");
716
+ backfillLineageTasks(database);
637
717
  ensureColumn(database, "asset_ledger_records", "first_seen_at", "text");
638
718
  ensureColumn(database, "asset_ledger_records", "indexed_by_run_id", "text");
639
719
  ensureColumn(database, "asset_ledger_sources", "first_seen_at", "text");
640
720
  ensureColumn(database, "asset_ledger_sources", "indexed_by_run_id", "text");
641
721
  ensureReviewStateValues(database);
722
+ ensureAgentClaimScopeValues(database);
723
+ ensureGenerationReceiptCheckValues(database);
642
724
  return database;
643
725
  }
726
+ function backfillLineageTasks(database) {
727
+ database.exec(`
728
+ create table if not exists lineage_schema_migrations (
729
+ key text primary key,
730
+ applied_at text not null
731
+ )
732
+ `);
733
+ const marker = database.prepare("select key from lineage_schema_migrations where key = 'lineage_tasks_backfilled_v1'").get();
734
+ if (marker) return;
735
+ database.exec("BEGIN IMMEDIATE");
736
+ try {
737
+ database.exec(`
738
+ insert or ignore into lineage_tasks (
739
+ id, project_id, root_asset_id, target_asset_id, task_type, status, instructions,
740
+ created_by, created_at, updated_at, metadata_json
741
+ )
742
+ select
743
+ s.project_id || ':' || s.root_asset_id || ':lineage-task:iterate:' || s.asset_id,
744
+ s.project_id,
745
+ s.root_asset_id,
746
+ s.asset_id,
747
+ 'iterate',
748
+ 'pending',
749
+ s.notes,
750
+ 'human',
751
+ s.selected_at,
752
+ s.selected_at,
753
+ null
754
+ from asset_selections s;
755
+
756
+ insert or ignore into lineage_tasks (
757
+ id, project_id, root_asset_id, target_asset_id, task_type, status, instructions,
758
+ created_by, created_at, updated_at, metadata_json
759
+ )
760
+ select
761
+ r.project_id || ':' || r.root_asset_id || ':lineage-task:reroll:' || r.node_asset_id,
762
+ r.project_id,
763
+ r.root_asset_id,
764
+ r.node_asset_id,
765
+ 'reroll',
766
+ 'pending',
767
+ r.notes,
768
+ r.requested_by,
769
+ r.created_at,
770
+ r.created_at,
771
+ null
772
+ from asset_reroll_requests r
773
+ where r.status = 'pending';
774
+ `);
775
+ database.prepare(`
776
+ insert into lineage_schema_migrations (key, applied_at)
777
+ values ('lineage_tasks_backfilled_v1', ?)
778
+ on conflict(key) do nothing
779
+ `).run(nowIso());
780
+ database.exec("COMMIT");
781
+ } catch (error) {
782
+ database.exec("ROLLBACK");
783
+ throw error;
784
+ }
785
+ }
644
786
  function ensureColumn(database, table, column, definition) {
645
787
  const rows = database.prepare(`pragma table_info(${table})`).all();
646
788
  if (!rows.some((row) => row.name === column)) database.exec(`alter table ${table} add column ${column} ${definition}`);
@@ -704,6 +846,132 @@ function ensureReviewStateValues(database) {
704
846
  drop table asset_reviews_old;
705
847
  `);
706
848
  }
849
+ function ensureAgentClaimScopeValues(database) {
850
+ const createSql = database.prepare("select sql from sqlite_master where type = 'table' and name = 'agent_claims'").get();
851
+ if (createSql?.sql?.includes("'lineage_task'")) return;
852
+ database.exec("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON; BEGIN IMMEDIATE");
853
+ try {
854
+ database.exec(`
855
+ alter table agent_claims rename to agent_claims_old;
856
+ create table agent_claims (
857
+ id text primary key,
858
+ token_hash text not null,
859
+ project_id text not null references projects(id),
860
+ channel text,
861
+ scope_type text not null check (scope_type in ('lineage_workspace', 'lineage_task', 'content_post', 'content_queue_lane', 'selection_set', 'project_channel')),
862
+ target_id text not null,
863
+ target_title text,
864
+ agent_id text,
865
+ agent_name text not null,
866
+ agent_kind text not null,
867
+ thread_id text,
868
+ status text not null check (status in ('active', 'expired', 'released', 'revoked', 'transferred')),
869
+ created_at text not null,
870
+ heartbeat_at text not null,
871
+ expires_at text not null,
872
+ released_at text,
873
+ revoked_at text,
874
+ revoked_by text,
875
+ override_reason text,
876
+ metadata_json text
877
+ );
878
+ insert into agent_claims
879
+ select * from agent_claims_old;
880
+ drop table agent_claims_old;
881
+ create unique index if not exists agent_claims_token_hash on agent_claims(token_hash);
882
+ create index if not exists agent_claims_project_status on agent_claims(project_id, status, heartbeat_at);
883
+ create index if not exists agent_claims_target on agent_claims(project_id, channel, scope_type, target_id, status);
884
+ `);
885
+ const violations = database.prepare("pragma foreign_key_check").all();
886
+ if (violations.length > 0) throw new Error(`Agent claim scope migration failed foreign key check: ${JSON.stringify(violations)}`);
887
+ database.exec("COMMIT");
888
+ } catch (error) {
889
+ database.exec("ROLLBACK");
890
+ throw error;
891
+ } finally {
892
+ database.exec("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON");
893
+ }
894
+ }
895
+ function tableCreateSql(database, table) {
896
+ const row = database.prepare("select sql from sqlite_master where type = 'table' and name = ?").get(table);
897
+ return row?.sql || "";
898
+ }
899
+ function ensureGenerationReceiptCheckValues(database) {
900
+ const jobsSql = tableCreateSql(database, "generation_jobs");
901
+ const inputsSql = tableCreateSql(database, "generation_job_inputs");
902
+ const needsJobsMigration = Boolean(jobsSql && !jobsSql.includes("'lineage_reroll'"));
903
+ const needsInputsMigration = Boolean(inputsSql && !inputsSql.includes("'reroll_target'"));
904
+ if (!needsJobsMigration && !needsInputsMigration) return;
905
+ database.exec("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON; BEGIN IMMEDIATE");
906
+ try {
907
+ if (needsJobsMigration) migrateGenerationJobsCheck(database);
908
+ if (needsInputsMigration) migrateGenerationJobInputsCheck(database);
909
+ const violations = database.prepare("pragma foreign_key_check").all();
910
+ if (violations.length > 0) throw new Error(`Generation receipt migration failed foreign key check: ${JSON.stringify(violations)}`);
911
+ database.exec("COMMIT");
912
+ } catch (error) {
913
+ database.exec("ROLLBACK");
914
+ throw error;
915
+ } finally {
916
+ database.exec("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON");
917
+ }
918
+ }
919
+ function migrateGenerationJobsCheck(database) {
920
+ database.exec(`
921
+ alter table generation_jobs rename to generation_jobs_legacy_check;
922
+ create table generation_jobs (
923
+ id text primary key,
924
+ project_id text not null references projects(id),
925
+ provider text not null default 'codex-handoff',
926
+ adapter_version text not null,
927
+ source_mode text not null check (source_mode in ('lineage_selection', 'lineage_reroll')),
928
+ root_asset_id text not null references assets(id),
929
+ prompt text not null,
930
+ expected_output_count integer not null check (expected_output_count > 0),
931
+ status text not null check (status in ('planned', 'imported', 'failed', 'cancelled')),
932
+ output_dir text,
933
+ handoff_json text,
934
+ created_at text not null,
935
+ updated_at text not null,
936
+ imported_at text
937
+ );
938
+ insert into generation_jobs (
939
+ id, project_id, provider, adapter_version, source_mode, root_asset_id, prompt,
940
+ expected_output_count, status, output_dir, handoff_json, created_at, updated_at, imported_at
941
+ )
942
+ select
943
+ id, project_id, provider, adapter_version, source_mode, root_asset_id, prompt,
944
+ expected_output_count, status, output_dir, handoff_json, created_at, updated_at, imported_at
945
+ from generation_jobs_legacy_check;
946
+ drop table generation_jobs_legacy_check;
947
+ create index if not exists generation_jobs_project_created on generation_jobs(project_id, created_at);
948
+ `);
949
+ }
950
+ function migrateGenerationJobInputsCheck(database) {
951
+ database.exec(`
952
+ alter table generation_job_inputs rename to generation_job_inputs_legacy_check;
953
+ create table generation_job_inputs (
954
+ id text primary key,
955
+ job_id text not null references generation_jobs(id) on delete cascade,
956
+ project_id text not null references projects(id),
957
+ asset_id text not null references assets(id),
958
+ root_asset_id text not null references assets(id),
959
+ role text not null check (role in ('lineage_next_base', 'reference', 'reroll_target')),
960
+ position integer not null,
961
+ selection_strategy text not null,
962
+ selection_snapshot_json text not null,
963
+ unique(job_id, asset_id, role)
964
+ );
965
+ insert into generation_job_inputs (
966
+ id, job_id, project_id, asset_id, root_asset_id, role, position, selection_strategy, selection_snapshot_json
967
+ )
968
+ select
969
+ id, job_id, project_id, asset_id, root_asset_id, role, position, selection_strategy, selection_snapshot_json
970
+ from generation_job_inputs_legacy_check;
971
+ drop table generation_job_inputs_legacy_check;
972
+ create index if not exists generation_job_inputs_job on generation_job_inputs(job_id, position);
973
+ `);
974
+ }
707
975
 
708
976
  // src/server/assetLedgerWorkflow.ts
709
977
  function ensureProject(database, project) {
@@ -1317,382 +1585,898 @@ function normalizeSelectionInput(fields) {
1317
1585
  return [...new Set([...fields.assetIds || [], fields.assetId || ""].map((assetId) => assetId.trim()).filter(Boolean))];
1318
1586
  }
1319
1587
 
1320
- // src/server/assetLineageWorkspaces.ts
1321
- var actors = /* @__PURE__ */ new Set(["human", "agent", "system"]);
1322
- var statuses = /* @__PURE__ */ new Set(["active", "paused", "archived"]);
1323
- var LineageWorkspaceError = class extends Error {
1324
- constructor(message, status = 400) {
1588
+ // src/server/assetLineageTasks.ts
1589
+ import { randomBytes as randomBytes2 } from "node:crypto";
1590
+
1591
+ // src/server/agentClaims.ts
1592
+ import { createHash as createHash2, randomBytes, timingSafeEqual } from "node:crypto";
1593
+ var defaultTtlSeconds = 20 * 60;
1594
+ var idleAfterSeconds = 5 * 60;
1595
+ var staleAfterSeconds = 15 * 60;
1596
+ var scopes = /* @__PURE__ */ new Set(["lineage_workspace", "lineage_task", "content_post", "content_queue_lane", "selection_set", "project_channel"]);
1597
+ var AgentClaimError = class extends Error {
1598
+ constructor(message, status = 400, code = "agent_claim_error", conflicts = []) {
1325
1599
  super(message);
1326
1600
  this.status = status;
1601
+ this.code = code;
1602
+ this.conflicts = conflicts;
1327
1603
  }
1328
1604
  status;
1605
+ code;
1606
+ conflicts;
1329
1607
  };
1330
- function isLineageWorkspaceError(error) {
1331
- return error instanceof LineageWorkspaceError;
1608
+ function isAgentClaimError(error) {
1609
+ return error instanceof AgentClaimError;
1332
1610
  }
1333
- function lineageWorkspaceId(project, rootAssetId) {
1334
- return `${project}:lineage-workspace:${rootAssetId}`;
1611
+ function parseClaimTtl(value) {
1612
+ if (!value) return defaultTtlSeconds;
1613
+ const match = value.trim().match(/^(\d+)(s|m|h)?$/);
1614
+ if (!match) throw new AgentClaimError(`Invalid claim ttl: ${value}`);
1615
+ const amount = Number(match[1]);
1616
+ const unit = match[2] || "s";
1617
+ const multiplier = unit === "h" ? 3600 : unit === "m" ? 60 : 1;
1618
+ const seconds = amount * multiplier;
1619
+ if (!Number.isInteger(seconds) || seconds < 30 || seconds > 24 * 60 * 60) {
1620
+ throw new AgentClaimError("Claim ttl must be between 30 seconds and 24 hours");
1621
+ }
1622
+ return seconds;
1335
1623
  }
1336
- function ensureProject2(database, project) {
1337
- const timestamp = nowIso();
1338
- database.prepare(`
1339
- insert into projects (id, product, created_at, updated_at)
1340
- values (?, ?, ?, ?)
1341
- on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
1342
- `).run(project, project, timestamp, timestamp);
1624
+ function randomId(prefix) {
1625
+ return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString("base64url").toLowerCase()}`;
1343
1626
  }
1344
- function normalizeStatus(value, fallback) {
1345
- const status = value || fallback;
1346
- if (!statuses.has(status)) throw new LineageWorkspaceError(`Unsupported lineage workspace status: ${status}`);
1347
- return status;
1627
+ function tokenHash(token) {
1628
+ return createHash2("sha256").update(token).digest("hex");
1348
1629
  }
1349
- function normalizeActor(value) {
1350
- const actor = value || "human";
1351
- if (!actors.has(actor)) throw new LineageWorkspaceError(`Unsupported lineage workspace actor: ${actor}`);
1352
- return actor;
1630
+ function safeEqual(a, b) {
1631
+ const left = Buffer.from(a);
1632
+ const right = Buffer.from(b);
1633
+ return left.length === right.length && timingSafeEqual(left, right);
1353
1634
  }
1354
- function requireAsset(database, project, assetId) {
1355
- const row = database.prepare("select id, title from assets where project_id = ? and id = ?").get(project, assetId);
1356
- if (!row) throw new LineageWorkspaceError(`Unknown indexed asset: ${assetId}`, 404);
1357
- return row;
1635
+ function expiresAtFrom(timestamp, ttlSeconds) {
1636
+ return new Date(new Date(timestamp).getTime() + ttlSeconds * 1e3).toISOString();
1358
1637
  }
1359
- function rowToWorkspace(row) {
1638
+ function metadataJson(metadata2) {
1639
+ return metadata2 ? JSON.stringify(metadata2) : null;
1640
+ }
1641
+ function parseMetadata(value) {
1642
+ if (typeof value !== "string" || !value) return void 0;
1643
+ try {
1644
+ const parsed = JSON.parse(value);
1645
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
1646
+ } catch {
1647
+ return void 0;
1648
+ }
1649
+ }
1650
+ function derivedState(row, now = /* @__PURE__ */ new Date()) {
1651
+ if (row.status !== "active") return row.status === "expired" ? "expired" : "stale";
1652
+ if (new Date(row.expires_at).getTime() <= now.getTime()) return "expired";
1653
+ const ageSeconds = Math.max(0, Math.floor((now.getTime() - new Date(row.heartbeat_at).getTime()) / 1e3));
1654
+ if (ageSeconds >= staleAfterSeconds) return "stale";
1655
+ if (ageSeconds >= idleAfterSeconds) return "idle";
1656
+ return "active";
1657
+ }
1658
+ function rowToClaim(row, now = /* @__PURE__ */ new Date()) {
1659
+ const heartbeatAt = String(row.heartbeat_at);
1360
1660
  return {
1361
1661
  id: String(row.id),
1362
1662
  project: String(row.project_id),
1363
- root_asset_id: String(row.root_asset_id),
1364
- title: String(row.title),
1663
+ channel: typeof row.channel === "string" ? row.channel : void 0,
1664
+ scope_type: String(row.scope_type),
1665
+ target_id: String(row.target_id),
1666
+ target_title: typeof row.target_title === "string" ? row.target_title : void 0,
1667
+ agent_id: typeof row.agent_id === "string" ? row.agent_id : void 0,
1668
+ agent_name: String(row.agent_name),
1669
+ agent_kind: String(row.agent_kind),
1670
+ thread_id: typeof row.thread_id === "string" ? row.thread_id : void 0,
1365
1671
  status: String(row.status),
1366
- notes: typeof row.notes === "string" ? row.notes : void 0,
1367
- created_by: String(row.created_by),
1368
- active_at: typeof row.active_at === "string" ? row.active_at : void 0,
1369
1672
  created_at: String(row.created_at),
1370
- updated_at: String(row.updated_at)
1673
+ heartbeat_at: heartbeatAt,
1674
+ expires_at: String(row.expires_at),
1675
+ released_at: typeof row.released_at === "string" ? row.released_at : void 0,
1676
+ revoked_at: typeof row.revoked_at === "string" ? row.revoked_at : void 0,
1677
+ revoked_by: typeof row.revoked_by === "string" ? row.revoked_by : void 0,
1678
+ override_reason: typeof row.override_reason === "string" ? row.override_reason : void 0,
1679
+ metadata: parseMetadata(row.metadata_json),
1680
+ heartbeat_age_seconds: Math.max(0, Math.floor((now.getTime() - new Date(heartbeatAt).getTime()) / 1e3)),
1681
+ derived_state: derivedState({
1682
+ expires_at: String(row.expires_at),
1683
+ heartbeat_at: heartbeatAt,
1684
+ status: String(row.status)
1685
+ }, now)
1371
1686
  };
1372
1687
  }
1373
- function workspaceById(database, project, id) {
1374
- const row = database.prepare("select * from lineage_workspaces where project_id = ? and id = ?").get(project, id);
1375
- return row ? rowToWorkspace(row) : null;
1688
+ function eventToRow(row) {
1689
+ return {
1690
+ claim_id: String(row.claim_id),
1691
+ event_type: String(row.event_type),
1692
+ actor: typeof row.actor === "string" ? row.actor : void 0,
1693
+ message: typeof row.message === "string" ? row.message : void 0,
1694
+ created_at: String(row.created_at),
1695
+ metadata: parseMetadata(row.metadata_json)
1696
+ };
1376
1697
  }
1377
- function workspaceByRoot(database, project, rootAssetId) {
1378
- const row = database.prepare("select * from lineage_workspaces where project_id = ? and root_asset_id = ?").get(project, rootAssetId);
1379
- return row ? rowToWorkspace(row) : null;
1698
+ function ensureProject2(database, project) {
1699
+ const timestamp = nowIso();
1700
+ database.prepare(`
1701
+ insert into projects (id, product, created_at, updated_at)
1702
+ values (?, ?, ?, ?)
1703
+ on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
1704
+ `).run(project, project, timestamp, timestamp);
1380
1705
  }
1381
- function knownRoots(database, project) {
1382
- const rows = database.prepare(`
1383
- select root_asset_id, max(selected_at) selected_at from asset_selections where project_id = ? group by root_asset_id
1384
- union
1385
- select root_asset_id, null selected_at from asset_layouts where project_id = ? group by root_asset_id
1386
- union
1387
- select parent_asset_id root_asset_id, null selected_at
1388
- from asset_edges
1389
- where project_id = ?
1390
- and parent_asset_id not in (select child_asset_id from asset_edges where project_id = ?)
1391
- group by parent_asset_id
1392
- `).all(project, project, project, project);
1393
- return rows.map((row) => ({ root_asset_id: row.root_asset_id, selected_at: row.selected_at || void 0 }));
1706
+ function recordEvent(database, claimId, eventType, actor, message, metadata2) {
1707
+ database.prepare(`
1708
+ insert into agent_claim_events (id, claim_id, event_type, actor, message, created_at, metadata_json)
1709
+ values (?, ?, ?, ?, ?, ?, ?)
1710
+ `).run(randomId("claim_event"), claimId, eventType, actor || null, message || null, nowIso(), metadataJson(metadata2));
1394
1711
  }
1395
- function seedLegacyWorkspaces(database, project) {
1396
- ensureProject2(database, project);
1712
+ function expireActiveClaims(database) {
1397
1713
  const timestamp = nowIso();
1398
- const statement = database.prepare(`
1399
- insert into lineage_workspaces (
1400
- id, project_id, root_asset_id, title, status, notes, created_by, active_at, created_at, updated_at
1401
- )
1402
- select ?, ?, a.id, a.title || ' lineage', 'active', null, 'system', ?, ?, ?
1403
- from assets a
1404
- where a.project_id = ? and a.id = ?
1405
- on conflict(project_id, root_asset_id) do nothing
1406
- `);
1407
- for (const root of knownRoots(database, project)) {
1408
- statement.run(
1409
- lineageWorkspaceId(project, root.root_asset_id),
1714
+ const expired = database.prepare(`
1715
+ select id from agent_claims where status = 'active' and expires_at <= ?
1716
+ `).all(timestamp);
1717
+ if (expired.length === 0) return;
1718
+ database.prepare(`
1719
+ update agent_claims set status = 'expired' where status = 'active' and expires_at <= ?
1720
+ `).run(timestamp);
1721
+ for (const claim of expired) recordEvent(database, claim.id, "expired", "system", "Claim expired after missed heartbeat.");
1722
+ }
1723
+ function normalizeScope(scopeType) {
1724
+ if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
1725
+ return scopeType;
1726
+ }
1727
+ function channelOverlaps(left, right) {
1728
+ return !left || !right || left === right;
1729
+ }
1730
+ function claimOverlaps(candidate, existing) {
1731
+ if (candidate.project !== existing.project) return false;
1732
+ if (!channelOverlaps(candidate.channel, existing.channel)) return false;
1733
+ if (candidate.scope_type === existing.scope_type && candidate.target_id === existing.target_id) return true;
1734
+ return candidate.scope_type === "project_channel" || existing.scope_type === "project_channel";
1735
+ }
1736
+ function activeClaims(database, project) {
1737
+ 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();
1738
+ return rows.map((row) => rowToClaim(row));
1739
+ }
1740
+ function findClaimById(database, claimId, project) {
1741
+ 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);
1742
+ return row ? rowToClaim(row) : null;
1743
+ }
1744
+ function findClaimRowByToken(database, claimToken) {
1745
+ const claimId = claimToken.split(".")[0];
1746
+ const row = database.prepare("select * from agent_claims where id = ?").get(claimId);
1747
+ if (!row) return null;
1748
+ return safeEqual(String(row.token_hash), tokenHash(claimToken)) ? row : null;
1749
+ }
1750
+ function denied(code, message, conflicts = []) {
1751
+ return { ok: false, code, message, conflicts };
1752
+ }
1753
+ function scopeAllowsWrite(claim, scopeType, targetId, writeKind) {
1754
+ if (claim.scope_type === scopeType && claim.target_id === targetId) return true;
1755
+ if (claim.scope_type === "project_channel") return true;
1756
+ if (claim.scope_type === "lineage_workspace" && scopeType === "lineage_workspace") return claim.target_id === targetId;
1757
+ if (claim.scope_type === "content_queue_lane" && writeKind === "content_queue_next") return true;
1758
+ return false;
1759
+ }
1760
+ function createAgentClaim(fields) {
1761
+ const project = fields.project.trim();
1762
+ const targetId = fields.targetId.trim();
1763
+ const agentName = fields.agentName.trim();
1764
+ if (!project) throw new AgentClaimError("Agent claim requires project");
1765
+ if (!targetId) throw new AgentClaimError("Agent claim requires target");
1766
+ if (!agentName) throw new AgentClaimError("Agent claim requires agent name");
1767
+ const scopeType = normalizeScope(fields.scopeType);
1768
+ const ttlSeconds = fields.ttlSeconds || defaultTtlSeconds;
1769
+ const database = lineageDb();
1770
+ try {
1771
+ ensureProject2(database, project);
1772
+ expireActiveClaims(database);
1773
+ const candidate = { project, channel: fields.channel?.trim() || void 0, scope_type: scopeType, target_id: targetId };
1774
+ const conflicts = activeClaims(database, project).filter((claim2) => claimOverlaps(candidate, claim2));
1775
+ if (conflicts.length > 0 && !fields.force) {
1776
+ throw new AgentClaimError("Target already has an active overlapping agent claim.", 409, "target_already_claimed", conflicts);
1777
+ }
1778
+ if (conflicts.length > 0 && !fields.reason?.trim()) {
1779
+ throw new AgentClaimError("Overriding an active claim requires --reason.", 400, "override_reason_required", conflicts);
1780
+ }
1781
+ const timestamp = nowIso();
1782
+ for (const conflict of conflicts) {
1783
+ database.prepare(`
1784
+ update agent_claims
1785
+ set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
1786
+ where id = ? and status = 'active'
1787
+ `).run(timestamp, agentName, fields.reason || null, conflict.id);
1788
+ recordEvent(database, conflict.id, "revoked", agentName, fields.reason || "Revoked by forced claim takeover.");
1789
+ recordEvent(database, conflict.id, "conflict", agentName, `Overridden by ${agentName}.`, { new_claim_target: targetId });
1790
+ }
1791
+ const id = randomId("claim");
1792
+ const secret = randomBytes(24).toString("base64url");
1793
+ const claimToken = `${id}.${secret}`;
1794
+ const expiresAt = expiresAtFrom(timestamp, ttlSeconds);
1795
+ database.prepare(`
1796
+ insert into agent_claims (
1797
+ id, token_hash, project_id, channel, scope_type, target_id, target_title, agent_id, agent_name,
1798
+ agent_kind, thread_id, status, created_at, heartbeat_at, expires_at, metadata_json
1799
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
1800
+ `).run(
1801
+ id,
1802
+ tokenHash(claimToken),
1410
1803
  project,
1411
- root.selected_at || null,
1804
+ candidate.channel || null,
1805
+ scopeType,
1806
+ targetId,
1807
+ fields.targetTitle?.trim() || null,
1808
+ fields.agentId?.trim() || null,
1809
+ agentName,
1810
+ fields.agentKind?.trim() || "codex",
1811
+ fields.threadId?.trim() || null,
1412
1812
  timestamp,
1413
1813
  timestamp,
1414
- project,
1415
- root.root_asset_id
1814
+ expiresAt,
1815
+ metadataJson(fields.metadata)
1416
1816
  );
1817
+ recordEvent(database, id, "created", agentName, `Claimed ${scopeType} ${targetId}.`, { ttl_seconds: ttlSeconds });
1818
+ const claim = findClaimById(database, id);
1819
+ return { ok: true, claim, claim_token: claimToken, conflicts_revoked: conflicts.map((conflict) => conflict.id) };
1820
+ } finally {
1821
+ database.close();
1417
1822
  }
1418
1823
  }
1419
- function listRows(database, project) {
1420
- return database.prepare(`
1421
- select * from lineage_workspaces
1422
- where project_id = ?
1423
- order by
1424
- case status when 'active' then 0 when 'paused' then 1 else 2 end,
1425
- active_at desc nulls last,
1426
- updated_at desc,
1427
- title
1428
- `).all(project).map(rowToWorkspace);
1824
+ function listAgentClaims(project) {
1825
+ const database = lineageDb();
1826
+ try {
1827
+ expireActiveClaims(database);
1828
+ 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();
1829
+ return { ok: true, claims: rows.map((row) => rowToClaim(row)), fetchedAt: nowIso() };
1830
+ } finally {
1831
+ database.close();
1832
+ }
1429
1833
  }
1430
- function activeWorkspace(database, project) {
1431
- const row = database.prepare(`
1432
- select * from lineage_workspaces
1433
- where project_id = ? and status != 'archived'
1434
- order by active_at desc nulls last, updated_at desc
1435
- limit 1
1436
- `).get(project);
1437
- return row ? rowToWorkspace(row) : null;
1834
+ function inspectAgentClaim(claimId, project) {
1835
+ const database = lineageDb();
1836
+ try {
1837
+ expireActiveClaims(database);
1838
+ const claim = findClaimById(database, claimId, project);
1839
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1840
+ const events = database.prepare("select * from agent_claim_events where claim_id = ? order by created_at").all(claim.id);
1841
+ return { ok: true, claim, events: events.map(eventToRow) };
1842
+ } finally {
1843
+ database.close();
1844
+ }
1438
1845
  }
1439
- function listLineageWorkspaces(project) {
1846
+ function heartbeatAgentClaim(claimToken, ttlSeconds = defaultTtlSeconds) {
1440
1847
  const database = lineageDb();
1441
1848
  try {
1442
- seedLegacyWorkspaces(database, project);
1443
- return {
1444
- project,
1445
- active_workspace: activeWorkspace(database, project),
1446
- workspaces: listRows(database, project),
1447
- fetchedAt: nowIso()
1448
- };
1849
+ expireActiveClaims(database);
1850
+ const row = findClaimRowByToken(database, claimToken);
1851
+ if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
1852
+ const claim = rowToClaim(row);
1853
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1854
+ const timestamp = nowIso();
1855
+ database.prepare("update agent_claims set heartbeat_at = ?, expires_at = ? where id = ?").run(timestamp, expiresAtFrom(timestamp, ttlSeconds), claim.id);
1856
+ recordEvent(database, claim.id, "heartbeat", claim.agent_name, "Claim heartbeat received.");
1857
+ return { ok: true, claim: findClaimById(database, claim.id) };
1449
1858
  } finally {
1450
1859
  database.close();
1451
1860
  }
1452
1861
  }
1453
- function inspectLineageWorkspace(project, workspaceId) {
1862
+ function releaseAgentClaim(claimToken) {
1454
1863
  const database = lineageDb();
1455
1864
  try {
1456
- seedLegacyWorkspaces(database, project);
1457
- const workspace = workspaceById(database, project, workspaceId) || workspaceByRoot(database, project, workspaceId);
1458
- if (!workspace) throw new LineageWorkspaceError(`Unknown lineage workspace: ${workspaceId}`, 404);
1459
- return workspace;
1865
+ expireActiveClaims(database);
1866
+ const row = findClaimRowByToken(database, claimToken);
1867
+ if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
1868
+ const claim = rowToClaim(row);
1869
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1870
+ const timestamp = nowIso();
1871
+ database.prepare("update agent_claims set status = 'released', released_at = ? where id = ?").run(timestamp, claim.id);
1872
+ recordEvent(database, claim.id, "released", claim.agent_name, "Claim released by token holder.");
1873
+ return { ok: true, claim: findClaimById(database, claim.id) };
1460
1874
  } finally {
1461
1875
  database.close();
1462
1876
  }
1463
1877
  }
1464
- function createLineageWorkspace(project, fields) {
1465
- const rootAssetId = fields.rootAssetId.trim();
1466
- if (!rootAssetId) throw new LineageWorkspaceError("Lineage workspace requires rootAssetId");
1467
- const status = normalizeStatus(fields.status, "active");
1468
- const actor = normalizeActor(fields.createdBy);
1878
+ function releaseStaleAgentClaim(project, claimId, fields) {
1879
+ if (!fields.confirmWrite) throw new AgentClaimError("Releasing a stale agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1880
+ if (!fields.reason?.trim()) throw new AgentClaimError("Releasing a stale agent claim requires a reason.", 400, "reason_required");
1469
1881
  const database = lineageDb();
1470
1882
  try {
1471
- const root = requireAsset(database, project, rootAssetId);
1883
+ expireActiveClaims(database);
1884
+ const claim = findClaimById(database, claimId, project);
1885
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1886
+ if (claim.status !== "active" || claim.derived_state !== "stale") {
1887
+ throw new AgentClaimError("Only stale active claims can be released without the claim token.", 409, "claim_not_stale", [claim]);
1888
+ }
1472
1889
  const timestamp = nowIso();
1473
- const workspace = {
1474
- id: lineageWorkspaceId(project, rootAssetId),
1475
- project,
1476
- root_asset_id: rootAssetId,
1477
- title: fields.title?.trim() || `${root.title} lineage`,
1478
- status,
1479
- notes: fields.notes?.trim() || void 0,
1480
- created_by: actor,
1481
- active_at: fields.activate !== false && status !== "archived" ? timestamp : void 0,
1482
- created_at: timestamp,
1483
- updated_at: timestamp
1484
- };
1485
- if (!fields.confirmWrite) return { ok: true, dryRun: true, workspace };
1486
- ensureProject2(database, project);
1487
- database.prepare(`
1488
- insert into lineage_workspaces (
1489
- id, project_id, root_asset_id, title, status, notes, created_by, active_at, created_at, updated_at
1490
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1491
- on conflict(project_id, root_asset_id) do update set
1492
- title = excluded.title,
1493
- status = excluded.status,
1494
- notes = excluded.notes,
1495
- active_at = coalesce(excluded.active_at, lineage_workspaces.active_at),
1496
- updated_at = excluded.updated_at
1497
- `).run(
1498
- workspace.id,
1499
- project,
1500
- workspace.root_asset_id,
1501
- workspace.title,
1502
- workspace.status,
1503
- workspace.notes || null,
1504
- workspace.created_by,
1505
- workspace.active_at || null,
1506
- workspace.created_at,
1507
- workspace.updated_at
1508
- );
1509
- return {
1510
- ok: true,
1511
- message: `Saved lineage workspace ${workspace.title}`,
1512
- workspace: workspaceById(database, project, workspace.id)
1513
- };
1890
+ 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);
1891
+ recordEvent(database, claim.id, "released", fields.actor || "human", fields.reason);
1892
+ return { ok: true, claim: findClaimById(database, claim.id) };
1514
1893
  } finally {
1515
1894
  database.close();
1516
1895
  }
1517
1896
  }
1518
- function updateLineageWorkspace(project, workspaceId, fields) {
1897
+ function revokeAgentClaim(project, claimId, fields) {
1898
+ if (!fields.confirmWrite) throw new AgentClaimError("Revoking an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1899
+ if (!fields.reason?.trim()) throw new AgentClaimError("Revoking an agent claim requires a reason.", 400, "reason_required");
1519
1900
  const database = lineageDb();
1520
1901
  try {
1521
- seedLegacyWorkspaces(database, project);
1522
- const current = workspaceById(database, project, workspaceId) || workspaceByRoot(database, project, workspaceId);
1523
- if (!current) throw new LineageWorkspaceError(`Unknown lineage workspace: ${workspaceId}`, 404);
1902
+ expireActiveClaims(database);
1903
+ const claim = findClaimById(database, claimId, project);
1904
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1524
1905
  const timestamp = nowIso();
1525
- const next = {
1526
- ...current,
1527
- title: fields.title?.trim() || current.title,
1528
- status: normalizeStatus(fields.status, current.status),
1529
- notes: fields.notes === void 0 ? current.notes : fields.notes.trim() || void 0,
1530
- active_at: fields.activate ? timestamp : current.active_at,
1531
- updated_at: timestamp
1532
- };
1533
- if (!fields.confirmWrite) return { ok: true, dryRun: true, workspace: next };
1534
1906
  database.prepare(`
1535
- update lineage_workspaces
1536
- set title = ?, status = ?, notes = ?, active_at = ?, updated_at = ?
1537
- where project_id = ? and id = ?
1538
- `).run(next.title, next.status, next.notes || null, next.active_at || null, timestamp, project, current.id);
1907
+ update agent_claims set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
1908
+ where id = ?
1909
+ `).run(timestamp, fields.actor || "human", fields.reason, claim.id);
1910
+ recordEvent(database, claim.id, "revoked", fields.actor || "human", fields.reason);
1911
+ return { ok: true, claim: findClaimById(database, claim.id) };
1912
+ } finally {
1913
+ database.close();
1914
+ }
1915
+ }
1916
+ function revokeAgentClaimInDatabase(database, project, claimId, fields) {
1917
+ expireActiveClaims(database);
1918
+ const claim = findClaimById(database, claimId, project);
1919
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1920
+ if (claim.status !== "active") return claim;
1921
+ const timestamp = nowIso();
1922
+ database.prepare(`
1923
+ update agent_claims set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
1924
+ where id = ? and status = 'active'
1925
+ `).run(timestamp, fields.actor || "human", fields.reason || null, claim.id);
1926
+ recordEvent(database, claim.id, "revoked", fields.actor || "human", fields.reason);
1927
+ return findClaimById(database, claim.id, project);
1928
+ }
1929
+ function transferAgentClaim(project, claimId, fields) {
1930
+ if (!fields.confirmWrite) throw new AgentClaimError("Transferring an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
1931
+ const toAgentName = fields.toAgentName.trim();
1932
+ if (!toAgentName) throw new AgentClaimError("Transfer requires toAgentName.", 400, "agent_name_required");
1933
+ const database = lineageDb();
1934
+ try {
1935
+ expireActiveClaims(database);
1936
+ const claim = findClaimById(database, claimId, project);
1937
+ if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
1938
+ if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
1939
+ database.prepare("update agent_claims set agent_name = ? where id = ?").run(toAgentName, claim.id);
1940
+ recordEvent(database, claim.id, "transferred", fields.actor || "human", fields.reason || `Transferred claim to ${toAgentName}.`, { to_agent_name: toAgentName });
1941
+ return { ok: true, claim: findClaimById(database, claim.id) };
1942
+ } finally {
1943
+ database.close();
1944
+ }
1945
+ }
1946
+ function recordAgentClaimWriteAllowed(claim, fields) {
1947
+ const database = lineageDb();
1948
+ try {
1949
+ recordEvent(database, claim.id, "write_allowed", claim.agent_name, `${fields.writeKind} allowed.`, {
1950
+ danger_level: fields.dangerLevel,
1951
+ target_id: fields.targetId,
1952
+ write_kind: fields.writeKind
1953
+ });
1954
+ return { ok: true };
1955
+ } finally {
1956
+ database.close();
1957
+ }
1958
+ }
1959
+ function validateAgentClaimForWrite(fields) {
1960
+ if (fields.dangerLevel === "danger" && !fields.confirmWrite) {
1961
+ return denied("human_confirmation_required", "Dangerous write requires explicit human confirmation.");
1962
+ }
1963
+ if (!fields.claimToken) return denied("claim_required", "Mutating agent write requires a matching claim token.");
1964
+ const database = lineageDb();
1965
+ try {
1966
+ expireActiveClaims(database);
1967
+ const row = findClaimRowByToken(database, fields.claimToken);
1968
+ if (!row) return denied("claim_token_invalid", "Unknown or invalid claim token.");
1969
+ const claim = rowToClaim(row);
1970
+ if (claim.status !== "active") return denied("claim_not_active", `Agent claim is ${claim.status}.`);
1971
+ if (new Date(claim.expires_at).getTime() <= Date.now()) return denied("claim_expired", "Agent claim has expired.");
1972
+ if (claim.project !== fields.project) return denied("claim_project_mismatch", `Claim project ${claim.project} does not match ${fields.project}.`, [claim]);
1973
+ if (fields.channel && claim.channel && claim.channel !== fields.channel) {
1974
+ return denied("claim_channel_mismatch", `Claim channel ${claim.channel} does not match ${fields.channel}.`, [claim]);
1975
+ }
1976
+ if (!scopeAllowsWrite(claim, fields.scopeType, fields.targetId, fields.writeKind)) {
1977
+ return denied("claim_scope_mismatch", `Claim does not cover ${fields.scopeType} ${fields.targetId}.`, [claim]);
1978
+ }
1979
+ if (fields.recordEvent !== false) {
1980
+ recordEvent(database, claim.id, "write_allowed", claim.agent_name, `${fields.writeKind} allowed.`, {
1981
+ danger_level: fields.dangerLevel,
1982
+ target_id: fields.targetId,
1983
+ write_kind: fields.writeKind
1984
+ });
1985
+ }
1986
+ return { ok: true, claim, warnings: [] };
1987
+ } finally {
1988
+ database.close();
1989
+ }
1990
+ }
1991
+
1992
+ // src/server/assetLineageTasks.ts
1993
+ var LineageTaskError = class extends Error {
1994
+ constructor(message, status = 400) {
1995
+ super(message);
1996
+ this.status = status;
1997
+ }
1998
+ status;
1999
+ };
2000
+ function isLineageTaskError(error) {
2001
+ return error instanceof LineageTaskError;
2002
+ }
2003
+ var activeStatuses = ["pending", "claimed", "in_progress"];
2004
+ var taskTypes = /* @__PURE__ */ new Set(["iterate", "reroll"]);
2005
+ var taskStatuses = /* @__PURE__ */ new Set(["pending", "claimed", "in_progress", "resolved", "cancelled"]);
2006
+ function taskIdFor(project, rootAssetId, targetAssetId, taskType) {
2007
+ return `${project}:${rootAssetId}:lineage-task:${taskType}:${targetAssetId}`;
2008
+ }
2009
+ function randomId2(prefix) {
2010
+ return `${prefix}_${Date.now().toString(36)}_${randomBytes2(6).toString("base64url").toLowerCase()}`;
2011
+ }
2012
+ function metadataJson2(metadata2) {
2013
+ return metadata2 ? JSON.stringify(metadata2) : null;
2014
+ }
2015
+ function metadataWithoutClaim(metadata2) {
2016
+ if (!metadata2) return void 0;
2017
+ const next = { ...metadata2 };
2018
+ delete next.claim_id;
2019
+ return Object.keys(next).length > 0 ? next : void 0;
2020
+ }
2021
+ function parseMetadata2(value) {
2022
+ if (typeof value !== "string" || !value) return void 0;
2023
+ try {
2024
+ const parsed = JSON.parse(value);
2025
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
2026
+ } catch {
2027
+ return void 0;
2028
+ }
2029
+ }
2030
+ function normalizeProject(project) {
2031
+ const trimmed = project.trim();
2032
+ if (!trimmed) throw new LineageTaskError("Lineage task requires project");
2033
+ return trimmed;
2034
+ }
2035
+ function normalizeTaskType(taskType) {
2036
+ if (!taskTypes.has(taskType)) throw new LineageTaskError(`Unsupported lineage task type: ${taskType}`);
2037
+ return taskType;
2038
+ }
2039
+ function normalizeStatus(status) {
2040
+ if (!taskStatuses.has(status)) throw new LineageTaskError(`Unsupported lineage task status: ${status}`);
2041
+ return status;
2042
+ }
2043
+ function normalizeActor(actor, label) {
2044
+ const trimmed = actor.trim();
2045
+ if (!trimmed) throw new LineageTaskError(`${label} is required`);
2046
+ return trimmed;
2047
+ }
2048
+ function requireAsset(database, project, assetId) {
2049
+ const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
2050
+ if (!row) throw new LineageTaskError(`Unknown indexed asset: ${assetId}`, 404);
2051
+ }
2052
+ function taskFromRow(row) {
2053
+ const metadata2 = parseMetadata2(row.metadata_json);
2054
+ const claimId = typeof metadata2?.claim_id === "string" ? metadata2.claim_id : void 0;
2055
+ return {
2056
+ id: String(row.id),
2057
+ project_id: String(row.project_id),
2058
+ root_asset_id: String(row.root_asset_id),
2059
+ target_asset_id: String(row.target_asset_id),
2060
+ task_type: String(row.task_type),
2061
+ status: String(row.status),
2062
+ instructions: typeof row.instructions === "string" ? row.instructions : void 0,
2063
+ created_by: String(row.created_by),
2064
+ created_at: String(row.created_at),
2065
+ updated_at: String(row.updated_at),
2066
+ claimed_at: typeof row.claimed_at === "string" ? row.claimed_at : void 0,
2067
+ started_at: typeof row.started_at === "string" ? row.started_at : void 0,
2068
+ resolved_at: typeof row.resolved_at === "string" ? row.resolved_at : void 0,
2069
+ cancelled_at: typeof row.cancelled_at === "string" ? row.cancelled_at : void 0,
2070
+ resolved_generation_job_id: typeof row.resolved_generation_job_id === "string" ? row.resolved_generation_job_id : void 0,
2071
+ resolved_asset_id: typeof row.resolved_asset_id === "string" ? row.resolved_asset_id : void 0,
2072
+ claimed_by_claim_id: claimId,
2073
+ metadata: metadata2
2074
+ };
2075
+ }
2076
+ function eventFromRow(row) {
2077
+ return {
2078
+ id: String(row.id),
2079
+ task_id: String(row.task_id),
2080
+ event_type: String(row.event_type),
2081
+ actor: typeof row.actor === "string" ? row.actor : void 0,
2082
+ message: typeof row.message === "string" ? row.message : void 0,
2083
+ created_at: String(row.created_at),
2084
+ metadata: parseMetadata2(row.metadata_json)
2085
+ };
2086
+ }
2087
+ function findTask(database, project, taskId) {
2088
+ const row = database.prepare("select * from lineage_tasks where project_id = ? and id = ?").get(project, taskId);
2089
+ return row ? taskFromRow(row) : null;
2090
+ }
2091
+ function requireTask(database, project, taskId) {
2092
+ const task = findTask(database, project, taskId);
2093
+ if (!task) throw new LineageTaskError(`Unknown lineage task: ${taskId}`, 404);
2094
+ return task;
2095
+ }
2096
+ function taskEvents(database, taskId) {
2097
+ const rows = database.prepare("select * from lineage_task_events where task_id = ? order by created_at, rowid").all(taskId);
2098
+ return rows.map(eventFromRow);
2099
+ }
2100
+ function recordEvent2(database, taskId, eventType, actor, message, metadata2) {
2101
+ database.prepare(`
2102
+ insert into lineage_task_events (id, task_id, event_type, actor, message, created_at, metadata_json)
2103
+ values (?, ?, ?, ?, ?, ?, ?)
2104
+ `).run(randomId2("lineage_task_event"), taskId, eventType, actor || null, message || null, nowIso(), metadataJson2(metadata2));
2105
+ }
2106
+ function taskWithEvents(database, project, taskId) {
2107
+ return { project, ok: true, task: requireTask(database, project, taskId), events: taskEvents(database, taskId) };
2108
+ }
2109
+ function assertChanged(result, message) {
2110
+ if (Number(result.changes) !== 1) throw new LineageTaskError(message, 409);
2111
+ }
2112
+ function transaction(database, callback) {
2113
+ database.exec("BEGIN IMMEDIATE");
2114
+ try {
2115
+ const value = callback();
2116
+ database.exec("COMMIT");
2117
+ return value;
2118
+ } catch (error) {
2119
+ database.exec("ROLLBACK");
2120
+ throw error;
2121
+ }
2122
+ }
2123
+ function taskReadWithEvents(database, project, taskId) {
2124
+ return { task: requireTask(database, project, taskId), events: taskEvents(database, taskId) };
2125
+ }
2126
+ function listLineageTasks(project, rootAssetId, statuses2 = activeStatuses) {
2127
+ const normalizedProject = normalizeProject(project);
2128
+ const normalizedStatuses = statuses2.map(normalizeStatus);
2129
+ const database = lineageDb();
2130
+ try {
2131
+ requireAsset(database, normalizedProject, rootAssetId);
2132
+ if (normalizedStatuses.length === 0) {
2133
+ return { project: normalizedProject, root_asset_id: rootAssetId, tasks: [], fetchedAt: nowIso() };
2134
+ }
2135
+ const placeholders2 = normalizedStatuses.map(() => "?").join(",");
2136
+ const rows = database.prepare(`
2137
+ select * from lineage_tasks
2138
+ where project_id = ? and root_asset_id = ? and status in (${placeholders2})
2139
+ order by updated_at desc, created_at desc, id
2140
+ `).all(normalizedProject, rootAssetId, ...normalizedStatuses);
2141
+ return { project: normalizedProject, root_asset_id: rootAssetId, tasks: rows.map(taskFromRow), fetchedAt: nowIso() };
2142
+ } finally {
2143
+ database.close();
2144
+ }
2145
+ }
2146
+ function getLineageTask(project, taskId) {
2147
+ const normalizedProject = normalizeProject(project);
2148
+ const database = lineageDb();
2149
+ try {
2150
+ return { project: normalizedProject, ...taskReadWithEvents(database, normalizedProject, taskId) };
2151
+ } finally {
2152
+ database.close();
2153
+ }
2154
+ }
2155
+ function upsertLineageTask(project, fields) {
2156
+ const normalizedProject = normalizeProject(project);
2157
+ const taskType = normalizeTaskType(fields.taskType);
2158
+ const taskId = taskIdFor(normalizedProject, fields.rootAssetId, fields.targetAssetId, taskType);
2159
+ const database = lineageDb();
2160
+ try {
2161
+ requireAsset(database, normalizedProject, fields.rootAssetId);
2162
+ requireAsset(database, normalizedProject, fields.targetAssetId);
2163
+ const existing = database.prepare(`
2164
+ select * from lineage_tasks
2165
+ where project_id = ? and root_asset_id = ? and target_asset_id = ? and task_type = ?
2166
+ and status in ('pending', 'claimed', 'in_progress')
2167
+ order by created_at desc limit 1
2168
+ `).get(normalizedProject, fields.rootAssetId, fields.targetAssetId, taskType);
2169
+ const instructions = fields.instructions?.trim() || void 0;
2170
+ if (existing) {
2171
+ const task = taskFromRow(existing);
2172
+ if (task.status !== "pending" && instructions !== void 0 && instructions !== task.instructions) {
2173
+ throw new LineageTaskError("Only pending lineage tasks can update instructions.", 409);
2174
+ }
2175
+ if (task.status === "pending" && instructions !== void 0 && instructions !== task.instructions) {
2176
+ const timestamp2 = nowIso();
2177
+ transaction(database, () => {
2178
+ const result = database.prepare(`
2179
+ update lineage_tasks
2180
+ set instructions = ?, updated_at = ?
2181
+ where id = ? and status = 'pending'
2182
+ `).run(instructions, timestamp2, task.id);
2183
+ assertChanged(result, "Only pending lineage tasks can update instructions.");
2184
+ recordEvent2(database, task.id, "instructions_updated", fields.createdBy, "Instructions updated.");
2185
+ });
2186
+ }
2187
+ return taskWithEvents(database, normalizedProject, task.id);
2188
+ }
2189
+ const closedTask = findTask(database, normalizedProject, taskId);
2190
+ if (closedTask) {
2191
+ const timestamp2 = nowIso();
2192
+ transaction(database, () => {
2193
+ const result = database.prepare(`
2194
+ update lineage_tasks
2195
+ set status = 'pending', instructions = ?, created_by = ?, updated_at = ?,
2196
+ claimed_at = null, started_at = null, resolved_at = null, cancelled_at = null,
2197
+ resolved_generation_job_id = null, resolved_asset_id = null, metadata_json = null
2198
+ where id = ? and status not in ('pending', 'claimed', 'in_progress')
2199
+ `).run(instructions || null, fields.createdBy, timestamp2, taskId);
2200
+ assertChanged(result, "Lineage task changed while reopening.");
2201
+ recordEvent2(database, taskId, "created", fields.createdBy, "Lineage task created.");
2202
+ });
2203
+ return taskWithEvents(database, normalizedProject, taskId);
2204
+ }
2205
+ const timestamp = nowIso();
2206
+ transaction(database, () => {
2207
+ database.prepare(`
2208
+ insert into lineage_tasks (
2209
+ id, project_id, root_asset_id, target_asset_id, task_type, status, instructions,
2210
+ created_by, created_at, updated_at, metadata_json
2211
+ ) values (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, null)
2212
+ `).run(taskId, normalizedProject, fields.rootAssetId, fields.targetAssetId, taskType, instructions || null, fields.createdBy, timestamp, timestamp);
2213
+ recordEvent2(database, taskId, "created", fields.createdBy, "Lineage task created.");
2214
+ });
2215
+ return taskWithEvents(database, normalizedProject, taskId);
2216
+ } finally {
2217
+ database.close();
2218
+ }
2219
+ }
2220
+ function updateLineageTaskInstructions(project, fields) {
2221
+ const normalizedProject = normalizeProject(project);
2222
+ const instructions = fields.instructions.trim();
2223
+ const database = lineageDb();
2224
+ try {
2225
+ const task = requireTask(database, normalizedProject, fields.taskId);
2226
+ if (task.status !== "pending") throw new LineageTaskError("Only pending lineage tasks can update instructions.", 409);
2227
+ const timestamp = nowIso();
2228
+ transaction(database, () => {
2229
+ const result = database.prepare(`
2230
+ update lineage_tasks
2231
+ set instructions = ?, updated_at = ?
2232
+ where id = ? and status = 'pending'
2233
+ `).run(instructions || null, timestamp, task.id);
2234
+ assertChanged(result, "Only pending lineage tasks can update instructions.");
2235
+ recordEvent2(database, task.id, "instructions_updated", "human", "Instructions updated.");
2236
+ });
2237
+ return taskWithEvents(database, normalizedProject, task.id);
2238
+ } finally {
2239
+ database.close();
2240
+ }
2241
+ }
2242
+ function addLineageTaskComment(project, fields) {
2243
+ const normalizedProject = normalizeProject(project);
2244
+ const actor = normalizeActor(fields.actor, "Comment actor");
2245
+ const message = fields.message.trim();
2246
+ if (!message) throw new LineageTaskError("Comment message is required");
2247
+ const database = lineageDb();
2248
+ try {
2249
+ const task = requireTask(database, normalizedProject, fields.taskId);
2250
+ transaction(database, () => {
2251
+ recordEvent2(database, task.id, "comment_added", actor, message);
2252
+ });
2253
+ return taskWithEvents(database, normalizedProject, task.id);
2254
+ } finally {
2255
+ database.close();
2256
+ }
2257
+ }
2258
+ function claimLineageTask(project, fields) {
2259
+ const normalizedProject = normalizeProject(project);
2260
+ const agentName = normalizeActor(fields.agentName, "Agent name");
2261
+ const beforeClaimDb = lineageDb();
2262
+ try {
2263
+ const task = requireTask(beforeClaimDb, normalizedProject, fields.taskId);
2264
+ if (task.status !== "pending") throw new LineageTaskError("Only pending lineage tasks can be claimed.", 409);
2265
+ } finally {
2266
+ beforeClaimDb.close();
2267
+ }
2268
+ const claimResult = createAgentClaim({
2269
+ agentName,
2270
+ project: normalizedProject,
2271
+ scopeType: "lineage_task",
2272
+ targetId: fields.taskId,
2273
+ targetTitle: fields.taskId
2274
+ });
2275
+ if (!claimResult.claim) throw new LineageTaskError("Unable to create lineage task claim.", 500);
2276
+ const claim = claimResult.claim;
2277
+ const database = lineageDb();
2278
+ try {
2279
+ const task = requireTask(database, normalizedProject, fields.taskId);
2280
+ if (task.status !== "pending") {
2281
+ releaseAgentClaim(claimResult.claim_token);
2282
+ throw new LineageTaskError("Only pending lineage tasks can be claimed.", 409);
2283
+ }
2284
+ const timestamp = nowIso();
2285
+ const metadata2 = { ...task.metadata || {}, claim_id: claim.id };
2286
+ try {
2287
+ transaction(database, () => {
2288
+ const result = database.prepare(`
2289
+ update lineage_tasks
2290
+ set status = 'claimed', claimed_at = ?, updated_at = ?, metadata_json = ?
2291
+ where id = ? and status = 'pending'
2292
+ `).run(timestamp, timestamp, metadataJson2(metadata2), task.id);
2293
+ assertChanged(result, "Only pending lineage tasks can be claimed.");
2294
+ recordEvent2(database, task.id, "claimed", agentName, "Lineage task claimed.", { claim_id: claim.id });
2295
+ });
2296
+ } catch (error) {
2297
+ releaseAgentClaim(claimResult.claim_token);
2298
+ throw error;
2299
+ }
1539
2300
  return {
1540
- ok: true,
1541
- message: `Updated lineage workspace ${next.title}`,
1542
- workspace: workspaceById(database, project, current.id)
2301
+ ...taskWithEvents(database, normalizedProject, task.id),
2302
+ claim,
2303
+ claim_token: claimResult.claim_token
1543
2304
  };
1544
2305
  } finally {
1545
2306
  database.close();
1546
2307
  }
1547
2308
  }
1548
- function activateLineageWorkspace(project, workspaceId, confirmWrite) {
1549
- return updateLineageWorkspace(project, workspaceId, { activate: true, status: "active", confirmWrite });
2309
+ function startLineageTask(project, fields) {
2310
+ const normalizedProject = normalizeProject(project);
2311
+ const precheckDatabase = lineageDb();
2312
+ try {
2313
+ const task = requireTask(precheckDatabase, normalizedProject, fields.taskId);
2314
+ if (task.status !== "claimed") throw new LineageTaskError("Only claimed lineage tasks can be started.", 409);
2315
+ } finally {
2316
+ precheckDatabase.close();
2317
+ }
2318
+ const validation = validateAgentClaimForWrite({
2319
+ claimToken: fields.claimToken,
2320
+ dangerLevel: "enforce",
2321
+ project: normalizedProject,
2322
+ recordEvent: false,
2323
+ scopeType: "lineage_task",
2324
+ targetId: fields.taskId,
2325
+ writeKind: "lineage_task_start"
2326
+ });
2327
+ if (!validation.ok) throw new LineageTaskError(validation.message, 409);
2328
+ const database = lineageDb();
2329
+ try {
2330
+ const task = requireTask(database, normalizedProject, fields.taskId);
2331
+ if (task.status !== "claimed") throw new LineageTaskError("Only claimed lineage tasks can be started.", 409);
2332
+ if (task.claimed_by_claim_id && task.claimed_by_claim_id !== validation.claim.id) {
2333
+ throw new LineageTaskError("Claim token does not match the task claim.", 409);
2334
+ }
2335
+ const timestamp = nowIso();
2336
+ const metadata2 = { ...task.metadata || {}, claim_id: validation.claim.id };
2337
+ transaction(database, () => {
2338
+ const result = database.prepare(`
2339
+ update lineage_tasks
2340
+ set status = 'in_progress', started_at = ?, updated_at = ?, metadata_json = ?
2341
+ where id = ? and status = 'claimed'
2342
+ `).run(timestamp, timestamp, metadataJson2(metadata2), task.id);
2343
+ assertChanged(result, "Only claimed lineage tasks can be started.");
2344
+ recordEvent2(database, task.id, "started", validation.claim.agent_name, "Lineage task started.", { claim_id: validation.claim.id });
2345
+ });
2346
+ recordAgentClaimWriteAllowed(validation.claim, {
2347
+ dangerLevel: "enforce",
2348
+ targetId: fields.taskId,
2349
+ writeKind: "lineage_task_start"
2350
+ });
2351
+ return taskWithEvents(database, normalizedProject, task.id);
2352
+ } finally {
2353
+ database.close();
2354
+ }
1550
2355
  }
1551
- function archiveLineageWorkspace(project, workspaceId, confirmWrite) {
2356
+ function overrideLineageTask(project, fields) {
2357
+ const normalizedProject = normalizeProject(project);
2358
+ const actor = normalizeActor(fields.actor, "Override actor");
2359
+ const reason = fields.reason.trim();
2360
+ if (!reason) throw new LineageTaskError("Override reason is required");
1552
2361
  const database = lineageDb();
1553
2362
  try {
1554
- seedLegacyWorkspaces(database, project);
1555
- const current = workspaceById(database, project, workspaceId) || workspaceByRoot(database, project, workspaceId);
1556
- if (!current) throw new LineageWorkspaceError(`Unknown lineage workspace: ${workspaceId}`, 404);
2363
+ const task = requireTask(database, normalizedProject, fields.taskId);
2364
+ if (!["claimed", "in_progress"].includes(task.status)) {
2365
+ throw new LineageTaskError(`Only claimed or in-progress lineage tasks can be overridden; task is ${task.status}.`, 409);
2366
+ }
1557
2367
  const timestamp = nowIso();
1558
- const next = {
1559
- ...current,
1560
- status: "archived",
1561
- active_at: void 0,
1562
- updated_at: timestamp
1563
- };
1564
- if (!confirmWrite) return { ok: true, dryRun: true, workspace: next };
1565
- database.prepare(`
1566
- update lineage_workspaces
1567
- set status = 'archived', active_at = null, updated_at = ?
1568
- where project_id = ? and id = ?
1569
- `).run(timestamp, project, current.id);
1570
- database.prepare("delete from asset_selections where project_id = ? and root_asset_id = ?").run(project, current.root_asset_id);
1571
- return {
1572
- ok: true,
1573
- message: `Archived lineage workspace ${current.title}`,
1574
- workspace: workspaceById(database, project, current.id)
1575
- };
2368
+ const instructions = fields.instructions === void 0 ? task.instructions : fields.instructions.trim() || void 0;
2369
+ const metadata2 = metadataWithoutClaim(task.metadata);
2370
+ transaction(database, () => {
2371
+ const result = database.prepare(`
2372
+ update lineage_tasks
2373
+ set status = 'pending', instructions = ?, claimed_at = null, started_at = null,
2374
+ updated_at = ?, metadata_json = ?
2375
+ where project_id = ? and id = ? and status in ('claimed', 'in_progress')
2376
+ `).run(instructions || null, timestamp, metadataJson2(metadata2), normalizedProject, task.id);
2377
+ assertChanged(result, `Only active lineage task ${task.id} could be overridden.`);
2378
+ if (task.claimed_by_claim_id) {
2379
+ revokeAgentClaimInDatabase(database, normalizedProject, task.claimed_by_claim_id, {
2380
+ actor,
2381
+ reason
2382
+ });
2383
+ }
2384
+ recordEvent2(database, task.id, "human_override", actor, reason, {
2385
+ previous_claim_id: task.claimed_by_claim_id,
2386
+ previous_status: task.status
2387
+ });
2388
+ });
2389
+ return taskWithEvents(database, normalizedProject, task.id);
1576
2390
  } finally {
1577
2391
  database.close();
1578
2392
  }
1579
2393
  }
1580
- function activeLineageWorkspaceRoot(project) {
2394
+ function cancelLineageTask(project, fields) {
2395
+ const normalizedProject = normalizeProject(project);
2396
+ const actor = normalizeActor(fields.actor, "Cancel actor");
1581
2397
  const database = lineageDb();
1582
2398
  try {
1583
- seedLegacyWorkspaces(database, project);
1584
- return activeWorkspace(database, project)?.root_asset_id;
2399
+ const task = requireTask(database, normalizedProject, fields.taskId);
2400
+ if (task.status === "cancelled") return { project: normalizedProject, ok: true, task, events: taskEvents(database, task.id) };
2401
+ if (!["pending", "claimed", "in_progress"].includes(task.status)) {
2402
+ throw new LineageTaskError(`Only open lineage tasks can be cancelled; task is ${task.status}.`, 409);
2403
+ }
2404
+ if (task.status !== "pending" && !fields.override) {
2405
+ throw new LineageTaskError("Cancelling an active lineage task requires override=true.", 409);
2406
+ }
2407
+ const overridingActiveTask = fields.override === true && task.status !== "pending";
2408
+ const metadata2 = overridingActiveTask ? metadataWithoutClaim(task.metadata) : task.metadata;
2409
+ const timestamp = nowIso();
2410
+ const cancelledTask = {
2411
+ ...task,
2412
+ cancelled_at: timestamp,
2413
+ claimed_at: overridingActiveTask ? void 0 : task.claimed_at,
2414
+ claimed_by_claim_id: overridingActiveTask ? void 0 : task.claimed_by_claim_id,
2415
+ started_at: overridingActiveTask ? void 0 : task.started_at,
2416
+ status: "cancelled",
2417
+ updated_at: timestamp,
2418
+ metadata: metadata2
2419
+ };
2420
+ if (!fields.confirmWrite) return { project: normalizedProject, ok: true, dryRun: true, task: cancelledTask, events: taskEvents(database, task.id) };
2421
+ transaction(database, () => {
2422
+ const result = database.prepare(`
2423
+ update lineage_tasks
2424
+ set status = 'cancelled', cancelled_at = ?, claimed_at = ?, started_at = ?,
2425
+ updated_at = ?, metadata_json = ?
2426
+ where id = ? and status = ?
2427
+ `).run(
2428
+ timestamp,
2429
+ overridingActiveTask ? null : task.claimed_at || null,
2430
+ overridingActiveTask ? null : task.started_at || null,
2431
+ timestamp,
2432
+ metadataJson2(metadata2),
2433
+ task.id,
2434
+ task.status
2435
+ );
2436
+ assertChanged(result, `Only ${task.status} lineage task ${task.id} could be cancelled.`);
2437
+ if (overridingActiveTask) {
2438
+ if (task.claimed_by_claim_id) {
2439
+ revokeAgentClaimInDatabase(database, normalizedProject, task.claimed_by_claim_id, {
2440
+ actor,
2441
+ reason: "Lineage task cancelled by human override."
2442
+ });
2443
+ }
2444
+ recordEvent2(database, task.id, "human_override", actor, "Lineage task cancelled by human override.", {
2445
+ previous_claim_id: task.claimed_by_claim_id,
2446
+ previous_status: task.status
2447
+ });
2448
+ }
2449
+ recordEvent2(database, task.id, "cancelled", actor, "Lineage task cancelled.");
2450
+ });
2451
+ return taskWithEvents(database, normalizedProject, task.id);
1585
2452
  } finally {
1586
2453
  database.close();
1587
2454
  }
1588
2455
  }
2456
+ function cancelLineageIterateTasksForAssets(project, fields) {
2457
+ const targetIds = fields.assetIds ? new Set(fields.assetIds) : void 0;
2458
+ return listLineageTasks(project, fields.rootAssetId, ["pending"]).tasks.filter((task) => task.task_type === "iterate").filter((task) => !targetIds || targetIds.has(task.target_asset_id)).map((task) => cancelLineageTask(project, {
2459
+ actor: fields.actor,
2460
+ confirmWrite: fields.confirmWrite,
2461
+ taskId: task.id
2462
+ }));
2463
+ }
1589
2464
 
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 = []) {
2465
+ // src/server/assetLineageWorkspaces.ts
2466
+ var actors = /* @__PURE__ */ new Set(["human", "agent", "system"]);
2467
+ var statuses = /* @__PURE__ */ new Set(["active", "paused", "archived"]);
2468
+ var LineageWorkspaceError = class extends Error {
2469
+ constructor(message, status = 400) {
1598
2470
  super(message);
1599
2471
  this.status = status;
1600
- this.code = code;
1601
- this.conflicts = conflicts;
1602
2472
  }
1603
2473
  status;
1604
- code;
1605
- conflicts;
1606
2474
  };
1607
- function isAgentClaimError(error) {
1608
- return error instanceof AgentClaimError;
1609
- }
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");
1620
- }
1621
- return seconds;
1622
- }
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
- };
2475
+ function isLineageWorkspaceError(error) {
2476
+ return error instanceof LineageWorkspaceError;
1686
2477
  }
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
- };
2478
+ function lineageWorkspaceId(project, rootAssetId) {
2479
+ return `${project}:lineage-workspace:${rootAssetId}`;
1696
2480
  }
1697
2481
  function ensureProject3(database, project) {
1698
2482
  const timestamp = nowIso();
@@ -1702,259 +2486,252 @@ function ensureProject3(database, project) {
1702
2486
  on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
1703
2487
  `).run(project, project, timestamp, timestamp);
1704
2488
  }
1705
- function recordEvent(database, claimId, eventType, actor, message, metadata2) {
1706
- database.prepare(`
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));
1710
- }
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.");
1721
- }
1722
- function normalizeScope(scopeType) {
1723
- if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
1724
- return scopeType;
1725
- }
1726
- function channelOverlaps(left, right) {
1727
- return !left || !right || left === right;
2489
+ function normalizeStatus2(value, fallback) {
2490
+ const status = value || fallback;
2491
+ if (!statuses.has(status)) throw new LineageWorkspaceError(`Unsupported lineage workspace status: ${status}`);
2492
+ return status;
1728
2493
  }
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";
2494
+ function normalizeActor2(value) {
2495
+ const actor = value || "human";
2496
+ if (!actors.has(actor)) throw new LineageWorkspaceError(`Unsupported lineage workspace actor: ${actor}`);
2497
+ return actor;
1734
2498
  }
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));
2499
+ function requireAsset2(database, project, assetId) {
2500
+ const row = database.prepare("select id, title from assets where project_id = ? and id = ?").get(project, assetId);
2501
+ if (!row) throw new LineageWorkspaceError(`Unknown indexed asset: ${assetId}`, 404);
2502
+ return row;
1738
2503
  }
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;
2504
+ function rowToWorkspace(row) {
2505
+ return {
2506
+ id: String(row.id),
2507
+ project: String(row.project_id),
2508
+ root_asset_id: String(row.root_asset_id),
2509
+ title: String(row.title),
2510
+ status: String(row.status),
2511
+ notes: typeof row.notes === "string" ? row.notes : void 0,
2512
+ created_by: String(row.created_by),
2513
+ active_at: typeof row.active_at === "string" ? row.active_at : void 0,
2514
+ created_at: String(row.created_at),
2515
+ updated_at: String(row.updated_at)
2516
+ };
1742
2517
  }
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;
2518
+ function workspaceById(database, project, id) {
2519
+ const row = database.prepare("select * from lineage_workspaces where project_id = ? and id = ?").get(project, id);
2520
+ return row ? rowToWorkspace(row) : null;
1748
2521
  }
1749
- function denied(code, message, conflicts = []) {
1750
- return { ok: false, code, message, conflicts };
2522
+ function workspaceByRoot(database, project, rootAssetId) {
2523
+ const row = database.prepare("select * from lineage_workspaces where project_id = ? and root_asset_id = ?").get(project, rootAssetId);
2524
+ return row ? rowToWorkspace(row) : null;
1751
2525
  }
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;
2526
+ function knownRoots(database, project) {
2527
+ const rows = database.prepare(`
2528
+ select root_asset_id, max(selected_at) selected_at from asset_selections where project_id = ? group by root_asset_id
2529
+ union
2530
+ select root_asset_id, null selected_at from asset_layouts where project_id = ? group by root_asset_id
2531
+ union
2532
+ select parent_asset_id root_asset_id, null selected_at
2533
+ from asset_edges
2534
+ where project_id = ?
2535
+ and parent_asset_id not in (select child_asset_id from asset_edges where project_id = ?)
2536
+ group by parent_asset_id
2537
+ `).all(project, project, project, project);
2538
+ return rows.map((row) => ({ root_asset_id: row.root_asset_id, selected_at: row.selected_at || void 0 }));
1758
2539
  }
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;
1768
- const database = lineageDb();
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),
2540
+ function seedLegacyWorkspaces(database, project) {
2541
+ ensureProject3(database, project);
2542
+ const timestamp = nowIso();
2543
+ const statement = database.prepare(`
2544
+ insert into lineage_workspaces (
2545
+ id, project_id, root_asset_id, title, status, notes, created_by, active_at, created_at, updated_at
2546
+ )
2547
+ select ?, ?, a.id, a.title || ' lineage', 'active', null, 'system', ?, ?, ?
2548
+ from assets a
2549
+ where a.project_id = ? and a.id = ?
2550
+ on conflict(project_id, root_asset_id) do nothing
2551
+ `);
2552
+ for (const root of knownRoots(database, project)) {
2553
+ statement.run(
2554
+ lineageWorkspaceId(project, root.root_asset_id),
1802
2555
  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,
2556
+ root.selected_at || null,
1811
2557
  timestamp,
1812
2558
  timestamp,
1813
- expiresAt,
1814
- metadataJson(fields.metadata)
2559
+ project,
2560
+ root.root_asset_id
1815
2561
  );
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 {
1820
- database.close();
1821
2562
  }
1822
2563
  }
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();
1831
- }
2564
+ function listRows(database, project) {
2565
+ return database.prepare(`
2566
+ select * from lineage_workspaces
2567
+ where project_id = ?
2568
+ order by
2569
+ case status when 'active' then 0 when 'paused' then 1 else 2 end,
2570
+ active_at desc nulls last,
2571
+ updated_at desc,
2572
+ title
2573
+ `).all(project).map(rowToWorkspace);
1832
2574
  }
1833
- function inspectAgentClaim(claimId, project) {
1834
- const database = lineageDb();
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
- }
2575
+ function activeWorkspace(database, project) {
2576
+ const row = database.prepare(`
2577
+ select * from lineage_workspaces
2578
+ where project_id = ? and status != 'archived'
2579
+ order by active_at desc nulls last, updated_at desc
2580
+ limit 1
2581
+ `).get(project);
2582
+ return row ? rowToWorkspace(row) : null;
1844
2583
  }
1845
- function heartbeatAgentClaim(claimToken, ttlSeconds = defaultTtlSeconds) {
2584
+ function listLineageWorkspaces(project) {
1846
2585
  const database = lineageDb();
1847
2586
  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) };
2587
+ seedLegacyWorkspaces(database, project);
2588
+ return {
2589
+ project,
2590
+ active_workspace: activeWorkspace(database, project),
2591
+ workspaces: listRows(database, project),
2592
+ fetchedAt: nowIso()
2593
+ };
1857
2594
  } finally {
1858
2595
  database.close();
1859
2596
  }
1860
2597
  }
1861
- function releaseAgentClaim(claimToken) {
2598
+ function inspectLineageWorkspace(project, workspaceId) {
1862
2599
  const database = lineageDb();
1863
2600
  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) };
2601
+ seedLegacyWorkspaces(database, project);
2602
+ const workspace = workspaceById(database, project, workspaceId) || workspaceByRoot(database, project, workspaceId);
2603
+ if (!workspace) throw new LineageWorkspaceError(`Unknown lineage workspace: ${workspaceId}`, 404);
2604
+ return workspace;
1873
2605
  } finally {
1874
2606
  database.close();
1875
2607
  }
1876
2608
  }
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");
2609
+ function createLineageWorkspace(project, fields) {
2610
+ const rootAssetId = fields.rootAssetId.trim();
2611
+ if (!rootAssetId) throw new LineageWorkspaceError("Lineage workspace requires rootAssetId");
2612
+ const status = normalizeStatus2(fields.status, "active");
2613
+ const actor = normalizeActor2(fields.createdBy);
1880
2614
  const database = lineageDb();
1881
2615
  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
- }
2616
+ const root = requireAsset2(database, project, rootAssetId);
1888
2617
  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) };
2618
+ const workspace = {
2619
+ id: lineageWorkspaceId(project, rootAssetId),
2620
+ project,
2621
+ root_asset_id: rootAssetId,
2622
+ title: fields.title?.trim() || `${root.title} lineage`,
2623
+ status,
2624
+ notes: fields.notes?.trim() || void 0,
2625
+ created_by: actor,
2626
+ active_at: fields.activate !== false && status !== "archived" ? timestamp : void 0,
2627
+ created_at: timestamp,
2628
+ updated_at: timestamp
2629
+ };
2630
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, workspace };
2631
+ ensureProject3(database, project);
2632
+ database.prepare(`
2633
+ insert into lineage_workspaces (
2634
+ id, project_id, root_asset_id, title, status, notes, created_by, active_at, created_at, updated_at
2635
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2636
+ on conflict(project_id, root_asset_id) do update set
2637
+ title = excluded.title,
2638
+ status = excluded.status,
2639
+ notes = excluded.notes,
2640
+ active_at = coalesce(excluded.active_at, lineage_workspaces.active_at),
2641
+ updated_at = excluded.updated_at
2642
+ `).run(
2643
+ workspace.id,
2644
+ project,
2645
+ workspace.root_asset_id,
2646
+ workspace.title,
2647
+ workspace.status,
2648
+ workspace.notes || null,
2649
+ workspace.created_by,
2650
+ workspace.active_at || null,
2651
+ workspace.created_at,
2652
+ workspace.updated_at
2653
+ );
2654
+ return {
2655
+ ok: true,
2656
+ message: `Saved lineage workspace ${workspace.title}`,
2657
+ workspace: workspaceById(database, project, workspace.id)
2658
+ };
1892
2659
  } finally {
1893
2660
  database.close();
1894
2661
  }
1895
2662
  }
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");
2663
+ function updateLineageWorkspace(project, workspaceId, fields) {
1899
2664
  const database = lineageDb();
1900
2665
  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");
2666
+ seedLegacyWorkspaces(database, project);
2667
+ const current = workspaceById(database, project, workspaceId) || workspaceByRoot(database, project, workspaceId);
2668
+ if (!current) throw new LineageWorkspaceError(`Unknown lineage workspace: ${workspaceId}`, 404);
1904
2669
  const timestamp = nowIso();
2670
+ const next = {
2671
+ ...current,
2672
+ title: fields.title?.trim() || current.title,
2673
+ status: normalizeStatus2(fields.status, current.status),
2674
+ notes: fields.notes === void 0 ? current.notes : fields.notes.trim() || void 0,
2675
+ active_at: fields.activate ? timestamp : current.active_at,
2676
+ updated_at: timestamp
2677
+ };
2678
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, workspace: next };
1905
2679
  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) };
2680
+ update lineage_workspaces
2681
+ set title = ?, status = ?, notes = ?, active_at = ?, updated_at = ?
2682
+ where project_id = ? and id = ?
2683
+ `).run(next.title, next.status, next.notes || null, next.active_at || null, timestamp, project, current.id);
2684
+ return {
2685
+ ok: true,
2686
+ message: `Updated lineage workspace ${next.title}`,
2687
+ workspace: workspaceById(database, project, current.id)
2688
+ };
1911
2689
  } finally {
1912
2690
  database.close();
1913
2691
  }
1914
2692
  }
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");
2693
+ function activateLineageWorkspace(project, workspaceId, confirmWrite) {
2694
+ return updateLineageWorkspace(project, workspaceId, { activate: true, status: "active", confirmWrite });
2695
+ }
2696
+ function archiveLineageWorkspace(project, workspaceId, confirmWrite) {
1919
2697
  const database = lineageDb();
1920
2698
  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) };
2699
+ seedLegacyWorkspaces(database, project);
2700
+ const current = workspaceById(database, project, workspaceId) || workspaceByRoot(database, project, workspaceId);
2701
+ if (!current) throw new LineageWorkspaceError(`Unknown lineage workspace: ${workspaceId}`, 404);
2702
+ const timestamp = nowIso();
2703
+ const next = {
2704
+ ...current,
2705
+ status: "archived",
2706
+ active_at: void 0,
2707
+ updated_at: timestamp
2708
+ };
2709
+ if (!confirmWrite) return { ok: true, dryRun: true, workspace: next };
2710
+ cancelLineageIterateTasksForAssets(project, {
2711
+ actor: "human",
2712
+ confirmWrite: true,
2713
+ rootAssetId: current.root_asset_id
2714
+ });
2715
+ database.prepare(`
2716
+ update lineage_workspaces
2717
+ set status = 'archived', active_at = null, updated_at = ?
2718
+ where project_id = ? and id = ?
2719
+ `).run(timestamp, project, current.id);
2720
+ database.prepare("delete from asset_selections where project_id = ? and root_asset_id = ?").run(project, current.root_asset_id);
2721
+ return {
2722
+ ok: true,
2723
+ message: `Archived lineage workspace ${current.title}`,
2724
+ workspace: workspaceById(database, project, current.id)
2725
+ };
1928
2726
  } finally {
1929
2727
  database.close();
1930
2728
  }
1931
2729
  }
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.");
2730
+ function activeLineageWorkspaceRoot(project) {
1937
2731
  const database = lineageDb();
1938
2732
  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: [] };
2733
+ seedLegacyWorkspaces(database, project);
2734
+ return activeWorkspace(database, project)?.root_asset_id;
1958
2735
  } finally {
1959
2736
  database.close();
1960
2737
  }
@@ -2066,7 +2843,7 @@ function indexLineageAssets(project = defaultProject) {
2066
2843
  database.close();
2067
2844
  return { catalog: catalog.length, local: local.length, total: catalog.length + local.length, database: lineageDbPath() };
2068
2845
  }
2069
- function requireAsset2(database, project, assetId) {
2846
+ function requireAsset3(database, project, assetId) {
2070
2847
  const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
2071
2848
  if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
2072
2849
  }
@@ -2085,6 +2862,11 @@ function rootFor(database, project, assetId) {
2085
2862
  }
2086
2863
  return assetId;
2087
2864
  }
2865
+ function assertCanonicalRoot(database, project, root) {
2866
+ requireAsset3(database, project, root);
2867
+ const canonicalRoot = rootFor(database, project, root);
2868
+ if (canonicalRoot !== root) throw new LineageError(`Asset ${root} is not a lineage root`, 400);
2869
+ }
2088
2870
  function explicitWorkspaceRoot(database, project, assetId) {
2089
2871
  const row = database.prepare(`
2090
2872
  select root_asset_id from lineage_workspaces
@@ -2118,7 +2900,7 @@ function lineageWriteClaimContext(database, project, assetId) {
2118
2900
  function getLineageWriteClaimContext(project, assetId) {
2119
2901
  const database = lineageDb();
2120
2902
  try {
2121
- requireAsset2(database, project, assetId);
2903
+ requireAsset3(database, project, assetId);
2122
2904
  return lineageWriteClaimContext(database, project, assetId);
2123
2905
  } finally {
2124
2906
  database.close();
@@ -2130,17 +2912,106 @@ function latestSelectedRoot(database, project) {
2130
2912
  }
2131
2913
  function resolveRoot(database, project, rootAssetId) {
2132
2914
  if (rootAssetId) {
2133
- requireAsset2(database, project, rootAssetId);
2915
+ requireAsset3(database, project, rootAssetId);
2134
2916
  return rootAssetId;
2135
2917
  }
2136
2918
  const root = activeLineageWorkspaceRoot(project) || latestSelectedRoot(database, project);
2137
2919
  if (!root) throw new LineageError("Lineage command requires --root unless a project selection exists");
2138
- requireAsset2(database, project, root);
2920
+ requireAsset3(database, project, root);
2139
2921
  return root;
2140
2922
  }
2141
2923
  function edgeId(project, parent, child) {
2142
2924
  return `${project}:${parent}:derived_from:${child}`;
2143
2925
  }
2926
+ function rerollRequestId(project, root, node, timestamp) {
2927
+ return `${project}:${root}:reroll:${node}:${Date.parse(timestamp) || Date.now()}`;
2928
+ }
2929
+ function rowString(value) {
2930
+ return typeof value === "string" && value.length > 0 ? value : void 0;
2931
+ }
2932
+ function rerollRequestFrom(row) {
2933
+ return {
2934
+ id: String(row.id),
2935
+ project_id: String(row.project_id),
2936
+ root_asset_id: String(row.root_asset_id),
2937
+ node_asset_id: String(row.node_asset_id),
2938
+ status: row.status,
2939
+ requested_by: row.requested_by,
2940
+ notes: rowString(row.notes),
2941
+ created_at: String(row.created_at),
2942
+ resolved_at: rowString(row.resolved_at)
2943
+ };
2944
+ }
2945
+ function tasksByTarget(tasks) {
2946
+ const byTarget = /* @__PURE__ */ new Map();
2947
+ for (const task of tasks) {
2948
+ if (task.task_type !== "iterate" && task.task_type !== "reroll") continue;
2949
+ const existing = byTarget.get(task.target_asset_id) || {};
2950
+ existing[task.task_type] = task;
2951
+ byTarget.set(task.target_asset_id, existing);
2952
+ }
2953
+ return byTarget;
2954
+ }
2955
+ function withRerollTask(request, task) {
2956
+ return task ? { ...request, task_id: task.id, task } : request;
2957
+ }
2958
+ function taskBackedRerollRequest(project, rootAssetId, task) {
2959
+ return {
2960
+ id: `${task.id}:request`,
2961
+ project_id: project,
2962
+ root_asset_id: rootAssetId,
2963
+ node_asset_id: task.target_asset_id,
2964
+ status: "pending",
2965
+ requested_by: task.created_by,
2966
+ notes: task.instructions,
2967
+ created_at: task.created_at,
2968
+ task_id: task.id,
2969
+ task
2970
+ };
2971
+ }
2972
+ function attemptFrom(row) {
2973
+ return {
2974
+ id: String(row.id),
2975
+ project_id: String(row.project_id),
2976
+ node_asset_id: String(row.node_asset_id),
2977
+ asset_id: String(row.asset_id),
2978
+ attempt_index: Number(row.attempt_index),
2979
+ source: row.source,
2980
+ prompt: rowString(row.prompt),
2981
+ generation_job_id: rowString(row.generation_job_id),
2982
+ file_path: rowString(row.file_path),
2983
+ checksum_sha256: rowString(row.checksum_sha256),
2984
+ created_at: String(row.created_at),
2985
+ promoted_at: rowString(row.promoted_at),
2986
+ is_current: Boolean(Number(row.is_current))
2987
+ };
2988
+ }
2989
+ function implicitAttempt(row, isCurrent = true) {
2990
+ const createdAt = row.asset_created_at || nowIso();
2991
+ return {
2992
+ id: `${row.project}:${row.asset_id}:attempt:implicit`,
2993
+ project_id: row.project,
2994
+ node_asset_id: row.asset_id,
2995
+ asset_id: row.asset_id,
2996
+ attempt_index: 1,
2997
+ source: "initial",
2998
+ file_path: row.local_path,
2999
+ checksum_sha256: row.checksum_sha256,
3000
+ created_at: createdAt,
3001
+ promoted_at: createdAt,
3002
+ is_current: isCurrent
3003
+ };
3004
+ }
3005
+ function withImplicitAttempt(physicalAttempts, row) {
3006
+ if (physicalAttempts.some((attempt) => attempt.source === "initial")) return physicalAttempts;
3007
+ return [...physicalAttempts, implicitAttempt(row, !physicalAttempts.some((attempt) => attempt.is_current))];
3008
+ }
3009
+ function assertNodeInRoot(database, project, root, node) {
3010
+ assertCanonicalRoot(database, project, root);
3011
+ requireAsset3(database, project, node);
3012
+ const nodeRoot = rootFor(database, project, node);
3013
+ if (nodeRoot !== root) throw new LineageError(`Asset ${node} is not in lineage rooted at ${root}`);
3014
+ }
2144
3015
  function canPreviewLocally(mediaType, localPath) {
2145
3016
  return Boolean(localPath && ["image", "video", "gif"].includes(mediaType));
2146
3017
  }
@@ -2151,8 +3022,8 @@ function localPreviewUrl(project, localPath) {
2151
3022
  }
2152
3023
  function linkLineageAssets(project, fields) {
2153
3024
  const database = lineageDb();
2154
- requireAsset2(database, project, fields.parentAssetId);
2155
- requireAsset2(database, project, fields.childAssetId);
3025
+ requireAsset3(database, project, fields.parentAssetId);
3026
+ requireAsset3(database, project, fields.childAssetId);
2156
3027
  if (fields.parentAssetId === fields.childAssetId) throw new LineageError("Lineage link cannot point to itself");
2157
3028
  const claimContext = lineageWriteClaimContext(database, project, fields.parentAssetId);
2158
3029
  try {
@@ -2203,20 +3074,37 @@ function descendants(database, project, root) {
2203
3074
  }
2204
3075
  function getLineageSnapshot(project, assetId) {
2205
3076
  const database = lineageDb();
2206
- requireAsset2(database, project, assetId);
3077
+ requireAsset3(database, project, assetId);
2207
3078
  const root = explicitWorkspaceRoot(database, project, assetId) || rootFor(database, project, assetId);
2208
3079
  const edges = descendants(database, project, root);
2209
- const ids = [.../* @__PURE__ */ new Set([root, ...edges.flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id])])];
3080
+ const selected = selectedRows(database, project, root);
3081
+ const tasks = listLineageTasks(project, root).tasks;
3082
+ const ids = [.../* @__PURE__ */ new Set([
3083
+ root,
3084
+ ...edges.flatMap((edge) => [edge.parent_asset_id, edge.child_asset_id]),
3085
+ ...selected.map((row) => row.asset_id),
3086
+ ...tasks.map((task) => task.target_asset_id)
3087
+ ])];
2210
3088
  const placeholders2 = ids.map(() => "?").join(",");
2211
3089
  const rows = database.prepare(`
2212
3090
  select a.id asset_id, a.project_id project, a.source, a.title, a.media_type, a.status, a.channel, a.campaign,
2213
3091
  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
3092
+ r.notes review_notes, a.created_at asset_created_at, l.x layout_x, l.y layout_y
2215
3093
  from assets a left join asset_reviews r on r.asset_id = a.id
2216
3094
  left join asset_layouts l on l.project_id = a.project_id and l.root_asset_id = ? and l.asset_id = a.id
2217
3095
  where a.project_id = ? and a.id in (${placeholders2})
2218
3096
  `).all(root, project, ...ids);
2219
- const selected = selectedRows(database, project, root);
3097
+ const attemptRows = ids.length > 0 ? database.prepare(`select * from asset_attempts where project_id = ? and node_asset_id in (${placeholders2}) order by node_asset_id, attempt_index desc`).all(project, ...ids) : [];
3098
+ const attemptsByNode = /* @__PURE__ */ new Map();
3099
+ for (const attempt of attemptRows.map(attemptFrom)) {
3100
+ attemptsByNode.set(attempt.node_asset_id, [...attemptsByNode.get(attempt.node_asset_id) || [], attempt]);
3101
+ }
3102
+ const lineageTasksByNode = tasksByTarget(tasks);
3103
+ const rerollRows = ids.length > 0 ? database.prepare(`select * from asset_reroll_requests where project_id = ? and root_asset_id = ? and status = 'pending' and node_asset_id in (${placeholders2}) order by created_at`).all(project, root, ...ids) : [];
3104
+ const rerollsByNode = new Map(rerollRows.map((row) => {
3105
+ const request = rerollRequestFrom(row);
3106
+ return [request.node_asset_id, withRerollTask(request, lineageTasksByNode.get(request.node_asset_id)?.reroll)];
3107
+ }));
2220
3108
  const childIds = new Set(edges.map((edge) => edge.parent_asset_id));
2221
3109
  const selectedIds = new Set(selected.map((row) => row.asset_id));
2222
3110
  const selections = selected.map((row) => ({
@@ -2228,13 +3116,21 @@ function getLineageSnapshot(project, assetId) {
2228
3116
  const selection = selections[0] || null;
2229
3117
  const nodes = rows.map((row) => {
2230
3118
  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;
3119
+ const { asset_created_at: _assetCreatedAt, layout_x: _layoutX, layout_y: _layoutY, ...node } = row;
2232
3120
  const nodeSelection = selections.find((item) => item.asset_id === row.asset_id);
3121
+ const attempts = withImplicitAttempt(attemptsByNode.get(row.asset_id) || [], row);
3122
+ const currentAttempt = attempts.find((attempt) => attempt.is_current) || attempts[0];
3123
+ const previewPath = currentAttempt?.file_path || row.local_path;
3124
+ const lineageTasks = lineageTasksByNode.get(row.asset_id);
2233
3125
  return {
2234
3126
  ...node,
3127
+ attempt_count: attempts.length,
3128
+ current_attempt: currentAttempt,
2235
3129
  is_latest: !childIds.has(row.asset_id),
3130
+ lineage_tasks: lineageTasks && Object.keys(lineageTasks).length > 0 ? lineageTasks : void 0,
2236
3131
  position,
2237
- preview_url: canPreviewLocally(row.media_type, row.local_path) ? localPreviewUrl(project, row.local_path) : void 0,
3132
+ preview_url: canPreviewLocally(row.media_type, previewPath) ? localPreviewUrl(project, previewPath) : void 0,
3133
+ reroll_request: rerollsByNode.get(row.asset_id),
2238
3134
  selection_note: nodeSelection?.notes,
2239
3135
  user_selected: selectedIds.has(row.asset_id)
2240
3136
  };
@@ -2247,6 +3143,7 @@ function getLineageSnapshot(project, assetId) {
2247
3143
  selected: selections.map((row) => row.asset_id),
2248
3144
  selection,
2249
3145
  selections,
3146
+ tasks,
2250
3147
  latest: nodes.filter((node) => node.is_latest).map((node) => node.asset_id),
2251
3148
  nodes,
2252
3149
  edges,
@@ -2256,8 +3153,8 @@ function getLineageSnapshot(project, assetId) {
2256
3153
  function updateLineageLayout(project, fields) {
2257
3154
  if (fields.positions.length === 0) throw new LineageError("Lineage layout requires at least one position");
2258
3155
  const database = lineageDb();
2259
- requireAsset2(database, project, fields.rootAssetId);
2260
- for (const position of fields.positions) requireAsset2(database, project, position.assetId);
3156
+ requireAsset3(database, project, fields.rootAssetId);
3157
+ for (const position of fields.positions) requireAsset3(database, project, position.assetId);
2261
3158
  try {
2262
3159
  requireLineageWorkspaceClaimForWrite({
2263
3160
  channel: assetChannel(database, project, fields.rootAssetId),
@@ -2293,7 +3190,25 @@ function getLineageNextAsset(project, rootAssetId) {
2293
3190
  const root = resolveRoot(database, project, rootAssetId);
2294
3191
  database.close();
2295
3192
  const snapshot = getLineageSnapshot(project, root);
2296
- const selectedNodes = snapshot.selected.map((assetId) => snapshot.nodes.find((node) => node.asset_id === assetId)).filter((node) => Boolean(node));
3193
+ const pendingIterateTasks = (snapshot.tasks || []).filter((task) => task.task_type === "iterate" && task.status === "pending");
3194
+ const taskIdsInSelectionOrder = [
3195
+ ...snapshot.selections.map((selection) => selection.asset_id).filter((assetId) => pendingIterateTasks.some((task) => task.target_asset_id === assetId)),
3196
+ ...pendingIterateTasks.map((task) => task.target_asset_id).filter((assetId) => !snapshot.selections.some((selection) => selection.asset_id === assetId))
3197
+ ];
3198
+ const taskSelectedIds = [...new Set(taskIdsInSelectionOrder)];
3199
+ const selectedIds = taskSelectedIds.length > 0 ? taskSelectedIds : snapshot.selected;
3200
+ const selectedNodes = selectedIds.map((assetId) => snapshot.nodes.find((node) => node.asset_id === assetId)).filter((node) => Boolean(node));
3201
+ const taskSelections = taskSelectedIds.map((assetId, position) => {
3202
+ const task = pendingIterateTasks.find((item) => item.target_asset_id === assetId);
3203
+ return {
3204
+ asset_id: assetId,
3205
+ notes: task?.instructions,
3206
+ position,
3207
+ selected_at: task?.created_at || nowIso()
3208
+ };
3209
+ });
3210
+ const selectedSelection = taskSelections.length > 0 ? taskSelections[0] : snapshot.selection;
3211
+ const selectedSelections = taskSelections.length > 0 ? taskSelections : snapshot.selections;
2297
3212
  const latestNodes = snapshot.nodes.filter((node) => snapshot.latest.includes(node.asset_id));
2298
3213
  const warnings = [];
2299
3214
  for (const selectedNode of selectedNodes) {
@@ -2311,9 +3226,9 @@ function getLineageNextAsset(project, rootAssetId) {
2311
3226
  next_asset: selectedNodes[0],
2312
3227
  next_assets: selectedNodes,
2313
3228
  latest: snapshot.latest,
2314
- selected: snapshot.selected,
2315
- selection: snapshot.selection,
2316
- selections: snapshot.selections,
3229
+ selected: selectedIds,
3230
+ selection: selectedSelection,
3231
+ selections: selectedSelections,
2317
3232
  candidates: latestNodes,
2318
3233
  warnings,
2319
3234
  fetchedAt: nowIso()
@@ -2330,9 +3245,9 @@ function getLineageNextAsset(project, rootAssetId) {
2330
3245
  next_asset: latestNodes[0],
2331
3246
  next_assets: [latestNodes[0]],
2332
3247
  latest: snapshot.latest,
2333
- selected: snapshot.selected,
2334
- selection: snapshot.selection,
2335
- selections: snapshot.selections,
3248
+ selected: selectedIds,
3249
+ selection: selectedSelection,
3250
+ selections: selectedSelections,
2336
3251
  candidates: latestNodes,
2337
3252
  warnings,
2338
3253
  fetchedAt: nowIso()
@@ -2348,9 +3263,9 @@ function getLineageNextAsset(project, rootAssetId) {
2348
3263
  next_asset: null,
2349
3264
  next_assets: [],
2350
3265
  latest: snapshot.latest,
2351
- selected: snapshot.selected,
2352
- selection: snapshot.selection,
2353
- selections: snapshot.selections,
3266
+ selected: selectedIds,
3267
+ selection: selectedSelection,
3268
+ selections: selectedSelections,
2354
3269
  candidates: latestNodes,
2355
3270
  warnings,
2356
3271
  fetchedAt: nowIso()
@@ -2368,49 +3283,281 @@ function getLineageChildren(project, parentAssetId) {
2368
3283
  fetchedAt: nowIso()
2369
3284
  };
2370
3285
  }
3286
+ function listLineageRerollRequests(project, rootAssetId) {
3287
+ const database = lineageDb();
3288
+ try {
3289
+ assertCanonicalRoot(database, project, rootAssetId);
3290
+ const rerollTasks = listLineageTasks(project, rootAssetId).tasks.filter((task) => task.task_type === "reroll");
3291
+ const pendingRerollTasks = listLineageTasks(project, rootAssetId, ["pending"]).tasks.filter((task) => task.task_type === "reroll");
3292
+ const rerollTaskByTarget = new Map(rerollTasks.map((task) => [task.target_asset_id, task]));
3293
+ const rows = database.prepare(`
3294
+ select * from asset_reroll_requests
3295
+ where project_id = ? and root_asset_id = ? and status = 'pending'
3296
+ order by created_at, id
3297
+ `).all(project, rootAssetId);
3298
+ const requests = rows.map((row) => {
3299
+ const request = rerollRequestFrom(row);
3300
+ return withRerollTask(request, rerollTaskByTarget.get(request.node_asset_id));
3301
+ });
3302
+ const legacyTargets = new Set(requests.map((request) => request.node_asset_id));
3303
+ for (const task of pendingRerollTasks) {
3304
+ if (!legacyTargets.has(task.target_asset_id)) requests.push(taskBackedRerollRequest(project, rootAssetId, task));
3305
+ }
3306
+ return { project, root_asset_id: rootAssetId, requests, fetchedAt: nowIso() };
3307
+ } finally {
3308
+ database.close();
3309
+ }
3310
+ }
3311
+ function getLineageAttempts(project, rootAssetId, nodeAssetId) {
3312
+ const database = lineageDb();
3313
+ try {
3314
+ assertNodeInRoot(database, project, rootAssetId, nodeAssetId);
3315
+ const rows = database.prepare(`
3316
+ select * from asset_attempts
3317
+ where project_id = ? and node_asset_id = ?
3318
+ order by attempt_index desc, created_at desc
3319
+ `).all(project, nodeAssetId);
3320
+ const asset = database.prepare("select id asset_id, project_id project, local_path, checksum_sha256, created_at asset_created_at from assets where project_id = ? and id = ?").get(project, nodeAssetId);
3321
+ return { project, root_asset_id: rootAssetId, node_asset_id: nodeAssetId, attempts: withImplicitAttempt(rows.map(attemptFrom), asset), fetchedAt: nowIso() };
3322
+ } finally {
3323
+ database.close();
3324
+ }
3325
+ }
3326
+ function promoteLineageAttempt(project, fields) {
3327
+ const database = lineageDb();
3328
+ try {
3329
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
3330
+ const asset = database.prepare("select id asset_id, project_id project, local_path, checksum_sha256, created_at asset_created_at from assets where project_id = ? and id = ?").get(project, fields.nodeAssetId);
3331
+ const rows = database.prepare(`
3332
+ select * from asset_attempts
3333
+ where project_id = ? and node_asset_id = ?
3334
+ order by attempt_index desc, created_at desc
3335
+ `).all(project, fields.nodeAssetId);
3336
+ const attempts = withImplicitAttempt(rows.map(attemptFrom), asset);
3337
+ const target = attempts.find((attempt2) => attempt2.id === fields.attemptId);
3338
+ if (!target) throw new LineageError(`Attempt ${fields.attemptId} is not in ${fields.nodeAssetId}`, 404);
3339
+ const timestamp = nowIso();
3340
+ const promotedAttempt = { ...target, is_current: true, promoted_at: timestamp };
3341
+ if (!fields.confirmWrite) {
3342
+ return {
3343
+ ok: true,
3344
+ dryRun: true,
3345
+ project,
3346
+ root_asset_id: fields.rootAssetId,
3347
+ node_asset_id: fields.nodeAssetId,
3348
+ attempt: promotedAttempt,
3349
+ attempts: attempts.map((attempt2) => ({ ...attempt2, is_current: attempt2.id === target.id, promoted_at: attempt2.id === target.id ? timestamp : attempt2.promoted_at })),
3350
+ fetchedAt: timestamp
3351
+ };
3352
+ }
3353
+ database.exec("BEGIN IMMEDIATE");
3354
+ try {
3355
+ database.prepare("update asset_attempts set is_current = 0 where project_id = ? and node_asset_id = ?").run(project, fields.nodeAssetId);
3356
+ if (target.source !== "initial") {
3357
+ const result = database.prepare(`
3358
+ update asset_attempts
3359
+ set is_current = 1, promoted_at = ?
3360
+ where project_id = ? and node_asset_id = ? and id = ?
3361
+ `).run(timestamp, project, fields.nodeAssetId, target.id);
3362
+ if (result.changes !== 1) throw new LineageError(`Attempt ${fields.attemptId} is not in ${fields.nodeAssetId}`, 404);
3363
+ }
3364
+ database.exec("COMMIT");
3365
+ } catch (error) {
3366
+ database.exec("ROLLBACK");
3367
+ throw error;
3368
+ }
3369
+ const refreshedRows = database.prepare(`
3370
+ select * from asset_attempts
3371
+ where project_id = ? and node_asset_id = ?
3372
+ order by attempt_index desc, created_at desc
3373
+ `).all(project, fields.nodeAssetId);
3374
+ const refreshedAttempts = withImplicitAttempt(refreshedRows.map(attemptFrom), asset);
3375
+ const attempt = refreshedAttempts.find((item) => item.id === target.id) || promotedAttempt;
3376
+ return {
3377
+ ok: true,
3378
+ project,
3379
+ root_asset_id: fields.rootAssetId,
3380
+ node_asset_id: fields.nodeAssetId,
3381
+ attempt,
3382
+ attempts: refreshedAttempts,
3383
+ fetchedAt: nowIso()
3384
+ };
3385
+ } finally {
3386
+ database.close();
3387
+ }
3388
+ }
3389
+ function markLineageRerollRequest(project, fields) {
3390
+ const database = lineageDb();
3391
+ try {
3392
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
3393
+ const existing = database.prepare(`
3394
+ select * from asset_reroll_requests
3395
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
3396
+ order by created_at desc limit 1
3397
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
3398
+ const timestamp = nowIso();
3399
+ const request = existing ? {
3400
+ ...rerollRequestFrom(existing),
3401
+ notes: fields.notes || rerollRequestFrom(existing).notes,
3402
+ requested_by: fields.requestedBy || rerollRequestFrom(existing).requested_by
3403
+ } : {
3404
+ id: rerollRequestId(project, fields.rootAssetId, fields.nodeAssetId, timestamp),
3405
+ project_id: project,
3406
+ root_asset_id: fields.rootAssetId,
3407
+ node_asset_id: fields.nodeAssetId,
3408
+ status: "pending",
3409
+ requested_by: fields.requestedBy || "human",
3410
+ notes: fields.notes,
3411
+ created_at: timestamp
3412
+ };
3413
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
3414
+ const taskResult = upsertLineageTask(project, {
3415
+ createdBy: request.requested_by,
3416
+ instructions: request.notes,
3417
+ rootAssetId: fields.rootAssetId,
3418
+ targetAssetId: fields.nodeAssetId,
3419
+ taskType: "reroll"
3420
+ });
3421
+ if (existing) {
3422
+ database.prepare(`
3423
+ update asset_reroll_requests
3424
+ set requested_by = ?, notes = ?
3425
+ where id = ?
3426
+ `).run(request.requested_by, request.notes || null, request.id);
3427
+ } else {
3428
+ database.prepare(`
3429
+ insert into asset_reroll_requests (id, project_id, root_asset_id, node_asset_id, status, requested_by, notes, created_at, resolved_at)
3430
+ values (?, ?, ?, ?, 'pending', ?, ?, ?, null)
3431
+ `).run(request.id, project, fields.rootAssetId, fields.nodeAssetId, request.requested_by, request.notes || null, request.created_at);
3432
+ }
3433
+ database.prepare(`
3434
+ insert into asset_reviews (asset_id, review_state, reviewed_at, ignored_at, notes, updated_at)
3435
+ values (?, 'needs_revision', ?, null, ?, ?)
3436
+ on conflict(asset_id) do update set
3437
+ review_state = excluded.review_state, reviewed_at = excluded.reviewed_at,
3438
+ ignored_at = excluded.ignored_at, notes = coalesce(excluded.notes, asset_reviews.notes), updated_at = excluded.updated_at
3439
+ `).run(fields.nodeAssetId, timestamp, request.notes || null, timestamp);
3440
+ return {
3441
+ ok: true,
3442
+ request: withRerollTask(rerollRequestFrom(database.prepare("select * from asset_reroll_requests where id = ?").get(request.id)), taskResult.task),
3443
+ task_id: taskResult.task.id,
3444
+ task: taskResult.task
3445
+ };
3446
+ } finally {
3447
+ database.close();
3448
+ }
3449
+ }
3450
+ function clearLineageRerollRequest(project, fields) {
3451
+ const database = lineageDb();
3452
+ try {
3453
+ assertNodeInRoot(database, project, fields.rootAssetId, fields.nodeAssetId);
3454
+ const existing = database.prepare(`
3455
+ select * from asset_reroll_requests
3456
+ where project_id = ? and root_asset_id = ? and node_asset_id = ? and status = 'pending'
3457
+ order by created_at desc limit 1
3458
+ `).get(project, fields.rootAssetId, fields.nodeAssetId);
3459
+ if (!existing) throw new LineageError(`No pending re-roll request for ${fields.nodeAssetId}`, 404);
3460
+ const timestamp = nowIso();
3461
+ const request = { ...rerollRequestFrom(existing), status: "cancelled", resolved_at: timestamp };
3462
+ if (!fields.confirmWrite) return { ok: true, dryRun: true, request };
3463
+ const task = listLineageTasks(project, fields.rootAssetId, ["pending"]).tasks.find((item) => item.task_type === "reroll" && item.target_asset_id === fields.nodeAssetId);
3464
+ const cancelledTask = task ? cancelLineageTask(project, {
3465
+ actor: "human",
3466
+ confirmWrite: true,
3467
+ taskId: task.id
3468
+ }).task : void 0;
3469
+ database.prepare(`
3470
+ update asset_reroll_requests
3471
+ set status = 'cancelled', resolved_at = ?
3472
+ where id = ?
3473
+ `).run(timestamp, request.id);
3474
+ return {
3475
+ ok: true,
3476
+ request: withRerollTask(request, cancelledTask),
3477
+ task_id: cancelledTask?.id,
3478
+ task: cancelledTask
3479
+ };
3480
+ } finally {
3481
+ database.close();
3482
+ }
3483
+ }
2371
3484
  function updateSelectedAsset(project, fields) {
2372
3485
  const database = lineageDb();
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) {
3486
+ let root;
3487
+ let current;
3488
+ let inputAssetIds;
3489
+ let mode;
3490
+ let limit;
3491
+ let nextIds;
3492
+ const notesByAsset = /* @__PURE__ */ new Map();
3493
+ try {
3494
+ inputAssetIds = normalizeSelectionInput(fields);
3495
+ root = fields.rootAssetId || (inputAssetIds[0] ? rootFor(database, project, inputAssetIds[0]) : "");
3496
+ if (!root) throw new LineageError("Selection requires rootAssetId or assetId");
3497
+ requireAsset3(database, project, root);
3498
+ for (const assetId of inputAssetIds) requireAsset3(database, project, assetId);
3499
+ mode = fields.mode || "replace";
3500
+ limit = fields.maxSelections || LINEAGE_NEXT_VARIATION_LIMIT;
3501
+ if (!fields.confirmWrite) {
3502
+ return { ok: true, dryRun: true, root_asset_id: root, asset_ids: inputAssetIds, mode, clear: Boolean(fields.clear), max_selections: limit };
3503
+ }
3504
+ current = selectedRows(database, project, root);
3505
+ nextIds = current.map((row) => row.asset_id);
3506
+ if (fields.clear) {
3507
+ nextIds = [];
3508
+ } else if (mode === "replace") {
3509
+ nextIds = inputAssetIds;
3510
+ } else if (mode === "add") {
3511
+ nextIds = [...nextIds, ...inputAssetIds];
3512
+ } else if (mode === "remove") {
3513
+ nextIds = nextIds.filter((assetId) => !inputAssetIds.includes(assetId));
3514
+ } else if (mode === "toggle") {
3515
+ for (const assetId of inputAssetIds) {
3516
+ nextIds = nextIds.includes(assetId) ? nextIds.filter((id) => id !== assetId) : [...nextIds, assetId];
3517
+ }
3518
+ }
3519
+ nextIds = [...new Set(nextIds)];
3520
+ if (!fields.clear && inputAssetIds.length === 0) throw new LineageError("Selection set requires assetId or assetIds");
3521
+ if (nextIds.length > limit) throw new LineageError(`Select at most ${limit} assets for next variation`);
3522
+ for (const assetId of nextIds) {
3523
+ const existing = current.find((row) => row.asset_id === assetId);
3524
+ notesByAsset.set(assetId, inputAssetIds.includes(assetId) ? fields.notes || existing?.notes : existing?.notes);
3525
+ }
3526
+ } finally {
2381
3527
  database.close();
2382
- return { ok: true, dryRun: true, root_asset_id: root, asset_ids: inputAssetIds, mode, clear: Boolean(fields.clear), max_selections: limit };
2383
3528
  }
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];
3529
+ const removedIds = current.map((row) => row.asset_id).filter((assetId) => !nextIds.includes(assetId));
3530
+ const openIterateTasks = listLineageTasks(project, root, ["pending"]).tasks.filter((task) => task.task_type === "iterate");
3531
+ for (const assetId of removedIds) {
3532
+ const task = openIterateTasks.find((item) => item.target_asset_id === assetId);
3533
+ if (task) {
3534
+ cancelLineageTask(project, {
3535
+ actor: "human",
3536
+ confirmWrite: true,
3537
+ taskId: task.id
3538
+ });
2397
3539
  }
2398
3540
  }
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);
3541
+ for (const assetId of nextIds) {
3542
+ upsertLineageTask(project, {
3543
+ createdBy: "human",
3544
+ instructions: notesByAsset.get(assetId),
3545
+ rootAssetId: root,
3546
+ targetAssetId: assetId,
3547
+ taskType: "iterate"
3548
+ });
3549
+ }
3550
+ const writeDatabase = lineageDb();
3551
+ writeDatabase.prepare("delete from asset_selections where project_id = ? and root_asset_id = ?").run(project, root);
2403
3552
  const timestamp = nowIso();
2404
- const insert = database.prepare(`
3553
+ const insert = writeDatabase.prepare(`
2405
3554
  insert into asset_selections (id, project_id, root_asset_id, asset_id, position, notes, selected_at)
2406
3555
  values (?, ?, ?, ?, ?, ?, ?)
2407
3556
  `);
2408
3557
  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);
3558
+ insert.run(selectionId(project, root, assetId), project, root, assetId, position, notesByAsset.get(assetId) || null, timestamp);
2412
3559
  });
2413
- database.close();
3560
+ writeDatabase.close();
2414
3561
  const message = nextIds.length === 0 ? `Cleared selected assets for ${root}` : `Selected ${nextIds.length} asset${nextIds.length === 1 ? "" : "s"} for ${root}`;
2415
3562
  return { ok: true, message, root_asset_id: root, asset_id: nextIds[0] || null, asset_ids: nextIds, mode };
2416
3563
  }
@@ -2418,7 +3565,7 @@ function updateAssetReview(project, fields) {
2418
3565
  const allowed = /* @__PURE__ */ new Set(["unreviewed", "approved", "needs_revision", "rejected", "ignored"]);
2419
3566
  if (!allowed.has(fields.reviewState)) throw new LineageError(`Unsupported review state: ${fields.reviewState}`);
2420
3567
  const database = lineageDb();
2421
- requireAsset2(database, project, fields.assetId);
3568
+ requireAsset3(database, project, fields.assetId);
2422
3569
  if (!fields.confirmWrite) {
2423
3570
  database.close();
2424
3571
  return { ok: true, dryRun: true, asset_id: fields.assetId, review_state: fields.reviewState, notes: fields.notes };
@@ -2446,6 +3593,9 @@ function lineageCommand(command, project, rootAssetId) {
2446
3593
  function linkChildCommand(project, rootAssetId) {
2447
3594
  return `${publicPackageCommand} link-child --project ${shellQuote(project)} --root ${shellQuote(rootAssetId)} --child <asset-id> --confirm-write --db ${shellQuote(lineageDbPath())} --json`;
2448
3595
  }
3596
+ function rerollImportGuidance(rootAssetId, targetAssetId) {
3597
+ return `Use lineage reroll plan --root ${rootAssetId} --target ${targetAssetId} and lineage reroll import instead.`;
3598
+ }
2449
3599
  function getLineageBrief(project, rootAssetId) {
2450
3600
  const next = getLineageNextAsset(project, rootAssetId);
2451
3601
  const assets = next.next_assets;
@@ -2492,6 +3642,14 @@ function getLineageBrief(project, rootAssetId) {
2492
3642
  function linkSelectedLineageChild(project, fields) {
2493
3643
  const next = getLineageNextAsset(project, fields.rootAssetId);
2494
3644
  if (!next.next_asset) throw new LineageError("Cannot link child until a next base is selected or unambiguous");
3645
+ const rerollRequests = listLineageRerollRequests(project, next.root_asset_id).requests;
3646
+ const pendingRerollForParent = rerollRequests.find((request) => request.node_asset_id === next.next_asset?.asset_id);
3647
+ if (pendingRerollForParent && fields.confirmWrite) {
3648
+ throw new LineageError(
3649
+ `Pending re-roll exists for ${pendingRerollForParent.node_asset_id}. lineage link-child creates a visible child variation edge; it does not re-roll the same node. ${rerollImportGuidance(next.root_asset_id, pendingRerollForParent.node_asset_id)} Cancel the re-roll first if you intentionally want a new child variation.`,
3650
+ 409
3651
+ );
3652
+ }
2495
3653
  if (fields.confirmWrite) {
2496
3654
  const claimContext = getLineageWriteClaimContext(project, next.next_asset.asset_id);
2497
3655
  const validation = validateAgentClaimForWrite({
@@ -2518,12 +3676,16 @@ function linkSelectedLineageChild(project, fields) {
2518
3676
  parent_asset_id: next.next_asset.asset_id,
2519
3677
  child_asset_id: fields.childAssetId,
2520
3678
  reference_asset_ids: next.next_assets.map((asset) => asset.asset_id),
2521
- warning: next.next_assets.length > 1 ? "Linked child to the primary selected base; add explicit edges to other selected references if the child derives from them too." : void 0
3679
+ warning: [
3680
+ pendingRerollForParent ? `Pending re-roll exists for ${pendingRerollForParent.node_asset_id}. link-child would create a visible child variation; use reroll plan/import to update the same node attempt.` : void 0,
3681
+ rerollRequests.length > 0 && !pendingRerollForParent ? `This lineage has ${rerollRequests.length} pending re-roll target(s). link-child is only for new visible child variations, not re-roll attempts.` : void 0,
3682
+ next.next_assets.length > 1 ? "Linked child to the primary selected base; add explicit edges to other selected references if the child derives from them too." : void 0
3683
+ ].filter(Boolean).join(" ") || void 0
2522
3684
  };
2523
3685
  }
2524
3686
 
2525
3687
  // src/server/assetLineageRemove.ts
2526
- function requireAsset3(database, project, assetId) {
3688
+ function requireAsset4(database, project, assetId) {
2527
3689
  const row = database.prepare("select id from assets where project_id = ? and id = ?").get(project, assetId);
2528
3690
  if (!row) throw new LineageError(`Unknown indexed asset: ${assetId}`, 404);
2529
3691
  }
@@ -2565,9 +3727,9 @@ function compactSelectionsAfterRemove(database, project, root, assetId, timestam
2565
3727
  }
2566
3728
  function removeLineageNode(project, fields) {
2567
3729
  const database = lineageDb();
2568
- requireAsset3(database, project, fields.assetId);
3730
+ requireAsset4(database, project, fields.assetId);
2569
3731
  const root = fields.rootAssetId || rootFor2(database, project, fields.assetId);
2570
- requireAsset3(database, project, root);
3732
+ requireAsset4(database, project, root);
2571
3733
  if (fields.assetId === root) {
2572
3734
  database.close();
2573
3735
  throw new LineageError("Cannot remove the root lineage node; archive the workspace or create a new root instead.");
@@ -2615,6 +3777,19 @@ function removeLineageNode(project, fields) {
2615
3777
  asset_preserved: true
2616
3778
  };
2617
3779
  }
3780
+ if (selectionRemoved) {
3781
+ try {
3782
+ cancelLineageIterateTasksForAssets(project, {
3783
+ actor: "human",
3784
+ assetIds: [fields.assetId],
3785
+ confirmWrite: true,
3786
+ rootAssetId: root
3787
+ });
3788
+ } catch (error) {
3789
+ database.close();
3790
+ throw error;
3791
+ }
3792
+ }
2618
3793
  try {
2619
3794
  database.exec("begin immediate transaction");
2620
3795
  const deleteEdge = database.prepare("delete from asset_edges where id = ? and project_id = ?");
@@ -3417,7 +4592,7 @@ function requireText(value, label) {
3417
4592
  if (!text) throw new AssetSelectionError(`Selection ${label} is required`);
3418
4593
  return text;
3419
4594
  }
3420
- function normalizeActor2(value, fallback) {
4595
+ function normalizeActor3(value, fallback) {
3421
4596
  const actor = value || fallback;
3422
4597
  if (!actors2.has(actor)) throw new AssetSelectionError(`Unsupported selection actor: ${actor}`);
3423
4598
  return actor;
@@ -3549,7 +4724,7 @@ function getAssetSelectionSnapshot(project) {
3549
4724
  }
3550
4725
  function selectCurrentAssets(project, fields) {
3551
4726
  const assetIds = [...new Set(fields.assetIds.map((assetId) => assetId.trim()).filter(Boolean))];
3552
- const actor = normalizeActor2(fields.selectedBy, "human");
4727
+ const actor = normalizeActor3(fields.selectedBy, "human");
3553
4728
  const preview = { assetIds, notes: fields.notes, project, selectedBy: actor };
3554
4729
  if (!fields.confirmWrite) return { ok: true, dryRun: true, message: `Would select ${assetIds.length} assets`, preview };
3555
4730
  const database = lineageDb();
@@ -3586,7 +4761,7 @@ function createReviewSet(project, fields) {
3586
4761
  const label = requireText(fields.label, "review set label");
3587
4762
  const key = fields.key?.trim() || `${slug(label)}-${Date.now().toString(36)}`;
3588
4763
  const assetIds = [...new Set((fields.assetIds || []).map((assetId) => assetId.trim()).filter(Boolean))];
3589
- const actor = normalizeActor2(fields.createdBy, "agent");
4764
+ const actor = normalizeActor3(fields.createdBy, "agent");
3590
4765
  const preview = { assetIds, createdBy: actor, key, label, notes: fields.notes, project };
3591
4766
  if (!fields.confirmWrite) return { ok: true, dryRun: true, message: `Would create review set ${label}`, preview };
3592
4767
  const database = lineageDb();
@@ -3623,7 +4798,7 @@ function activeReviewSet(database, project, setId2) {
3623
4798
  function chooseReviewSetLabels(project, fields) {
3624
4799
  const labels = [...new Set(fields.labels.map((label) => label.trim().toUpperCase()).filter(Boolean))];
3625
4800
  if (labels.length === 0) throw new AssetSelectionError("At least one variation label is required");
3626
- const actor = normalizeActor2(fields.selectedBy, "human");
4801
+ const actor = normalizeActor3(fields.selectedBy, "human");
3627
4802
  const database = lineageDb();
3628
4803
  try {
3629
4804
  const reviewSet2 = activeReviewSet(database, project, fields.setId);
@@ -4734,7 +5909,7 @@ function itemFromFile(file) {
4734
5909
  };
4735
5910
  }
4736
5911
  function demoMarkdownItems(kind) {
4737
- const root = join7(repoRoot, "demo-project", "channels");
5912
+ const root = process.env.LINEAGE_CONTENT_SOURCE_ROOT || join7(repoRoot, "demo-project", "channels");
4738
5913
  if (!existsSync4(root)) return [];
4739
5914
  return walk(root).map(itemFromFile).filter((item) => Boolean(item)).filter((item) => kind === "all" || `${item.kind}s` === kind).sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
4740
5915
  }
@@ -5136,7 +6311,7 @@ function loadGenerationJob(database, project, id) {
5136
6311
  project_id: String(row.project_id),
5137
6312
  provider: row.provider,
5138
6313
  adapter_version: String(row.adapter_version),
5139
- source_mode: "lineage_selection",
6314
+ source_mode: String(row.source_mode),
5140
6315
  root_asset_id: String(row.root_asset_id),
5141
6316
  prompt: String(row.prompt),
5142
6317
  expected_output_count: Number(row.expected_output_count),
@@ -5182,6 +6357,64 @@ function listImageGenerationJobs(project = defaultProject, fields = {}) {
5182
6357
  }
5183
6358
  }
5184
6359
 
6360
+ // src/server/lineageTaskRoutes.ts
6361
+ function stringBody3(req, key) {
6362
+ const value = req.body[key];
6363
+ return typeof value === "string" ? value : void 0;
6364
+ }
6365
+ function boolBody3(req, key) {
6366
+ return req.body[key] === true;
6367
+ }
6368
+ function registerLineageTaskRoutes(app2, projectFrom2, asyncRoute2) {
6369
+ app2.get("/api/lineage/:rootAssetId/tasks", asyncRoute2((req, res) => {
6370
+ res.json(listLineageTasks(projectFrom2(req), req.params.rootAssetId));
6371
+ }));
6372
+ app2.get("/api/lineage/tasks/:taskId", asyncRoute2((req, res) => {
6373
+ res.json(getLineageTask(projectFrom2(req), req.params.taskId));
6374
+ }));
6375
+ app2.post("/api/lineage/tasks/:taskId/instructions", asyncRoute2((req, res) => {
6376
+ res.json(updateLineageTaskInstructions(projectFrom2(req), {
6377
+ instructions: stringBody3(req, "instructions") || "",
6378
+ taskId: req.params.taskId
6379
+ }));
6380
+ }));
6381
+ app2.post("/api/lineage/tasks/:taskId/comment", asyncRoute2((req, res) => {
6382
+ res.json(addLineageTaskComment(projectFrom2(req), {
6383
+ actor: stringBody3(req, "actor") || "",
6384
+ message: stringBody3(req, "message") || stringBody3(req, "comment") || "",
6385
+ taskId: req.params.taskId
6386
+ }));
6387
+ }));
6388
+ app2.post("/api/lineage/tasks/:taskId/claim", asyncRoute2((req, res) => {
6389
+ res.json(claimLineageTask(projectFrom2(req), {
6390
+ agentName: stringBody3(req, "agentName") || stringBody3(req, "agent_name") || "",
6391
+ taskId: req.params.taskId
6392
+ }));
6393
+ }));
6394
+ app2.post("/api/lineage/tasks/:taskId/start", asyncRoute2((req, res) => {
6395
+ res.json(startLineageTask(projectFrom2(req), {
6396
+ claimToken: stringBody3(req, "claimToken") || stringBody3(req, "claim_token") || "",
6397
+ taskId: req.params.taskId
6398
+ }));
6399
+ }));
6400
+ app2.post("/api/lineage/tasks/:taskId/cancel", asyncRoute2((req, res) => {
6401
+ res.json(cancelLineageTask(projectFrom2(req), {
6402
+ actor: stringBody3(req, "actor") || "",
6403
+ confirmWrite: boolBody3(req, "confirmWrite") || boolBody3(req, "confirm_write"),
6404
+ override: boolBody3(req, "override"),
6405
+ taskId: req.params.taskId
6406
+ }));
6407
+ }));
6408
+ app2.post("/api/lineage/tasks/:taskId/override", asyncRoute2((req, res) => {
6409
+ res.json(overrideLineageTask(projectFrom2(req), {
6410
+ actor: stringBody3(req, "actor") || "",
6411
+ instructions: stringBody3(req, "instructions"),
6412
+ reason: stringBody3(req, "reason") || "",
6413
+ taskId: req.params.taskId
6414
+ }));
6415
+ }));
6416
+ }
6417
+
5185
6418
  // src/server/assetLineageDemo.ts
5186
6419
  import { createHash as createHash3 } from "node:crypto";
5187
6420
  import { copyFileSync as copyFileSync2, existsSync as existsSync5, mkdirSync as mkdirSync5, mkdtempSync, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
@@ -5245,6 +6478,15 @@ function swissifierRelativePath(manifest, asset) {
5245
6478
  function swissifierFilePath(manifest, asset) {
5246
6479
  return join8(repoRoot, ".asset-scratch", swissifierRelativePath(manifest, asset));
5247
6480
  }
6481
+ function swissifierRerollRelativePath(manifest, attempt) {
6482
+ return join8(manifest.media.target_dir, "reroll-attempts", attempt.file);
6483
+ }
6484
+ function swissifierRerollFilePath(manifest, attempt) {
6485
+ return join8(repoRoot, ".asset-scratch", swissifierRerollRelativePath(manifest, attempt));
6486
+ }
6487
+ function swissifierRerollFixturePath(attempt) {
6488
+ return join8(dirname3(swissifierManifestPath), "swissifier-rerolls", attempt.file);
6489
+ }
5248
6490
  function swissifierSourcePath(manifest, asset, sourceDir) {
5249
6491
  const direct = join8(sourceDir, asset.file);
5250
6492
  if (existsSync5(direct)) return direct;
@@ -5542,6 +6784,27 @@ function writeDemoFiles(project) {
5542
6784
  for (const asset of demoAssets) writeDemoFile(project, asset);
5543
6785
  return ids;
5544
6786
  }
6787
+ function swissifierRerollAsset(attempt) {
6788
+ const source = swissifierRerollFixturePath(attempt);
6789
+ if (!existsSync5(source)) throw new Error(`Missing Swissifier re-roll fixture media: ${attempt.file}`);
6790
+ const body = readFileSync4(source);
6791
+ if (body.length !== attempt.size_bytes) throw new Error(`Unexpected Swissifier re-roll fixture size for ${attempt.file}`);
6792
+ const checksumSha256 = sha256Hex(body);
6793
+ if (checksumSha256 !== attempt.checksum_sha256) throw new Error(`Checksum mismatch for Swissifier re-roll fixture: ${attempt.file}`);
6794
+ return {
6795
+ assetId: `local-${checksumSha256.slice(0, 12)}`,
6796
+ checksumSha256,
6797
+ sizeBytes: body.length
6798
+ };
6799
+ }
6800
+ function writeSwissifierRerollFiles(manifest) {
6801
+ for (const attempt of manifest.reroll_attempts || []) {
6802
+ swissifierRerollAsset(attempt);
6803
+ const target = swissifierRerollFilePath(manifest, attempt);
6804
+ mkdirSync5(dirname3(target), { recursive: true });
6805
+ copyFileSync2(swissifierRerollFixturePath(attempt), target);
6806
+ }
6807
+ }
5545
6808
  function upsertDemoAssets(project, ids) {
5546
6809
  const database = lineageDb();
5547
6810
  const timestamp = nowIso();
@@ -5632,10 +6895,100 @@ function upsertSwissifierAssets(project, manifest) {
5632
6895
  );
5633
6896
  reviewStatement.run(asset.asset_id, timestamp);
5634
6897
  }
6898
+ for (const attempt of manifest.reroll_attempts || []) {
6899
+ const generated = swissifierRerollAsset(attempt);
6900
+ assetStatement.run(
6901
+ generated.assetId,
6902
+ project,
6903
+ swissifierRerollRelativePath(manifest, attempt),
6904
+ generated.checksumSha256,
6905
+ attempt.title,
6906
+ "planned",
6907
+ "local",
6908
+ manifest.campaign,
6909
+ manifest.audience,
6910
+ generated.sizeBytes,
6911
+ attempt.content_type,
6912
+ timestamp,
6913
+ timestamp,
6914
+ timestamp
6915
+ );
6916
+ reviewStatement.run(generated.assetId, timestamp);
6917
+ }
6918
+ } finally {
6919
+ database.close();
6920
+ }
6921
+ const attemptCount = manifest.reroll_attempts?.length || 0;
6922
+ return { catalog: 0, local: manifest.assets.length + attemptCount, total: manifest.assets.length + attemptCount };
6923
+ }
6924
+ function upsertSwissifierRerollAttempts(project, manifest) {
6925
+ const attempts = manifest.reroll_attempts || [];
6926
+ if (attempts.length === 0) return { total: 0 };
6927
+ const database = lineageDb();
6928
+ const timestamp = nowIso();
6929
+ try {
6930
+ database.exec("BEGIN IMMEDIATE");
6931
+ try {
6932
+ const nodes = new Set(attempts.map((attempt) => attempt.node_asset_id));
6933
+ for (const nodeAssetId of nodes) {
6934
+ database.prepare("update asset_attempts set is_current = 0 where project_id = ? and node_asset_id = ?").run(project, nodeAssetId);
6935
+ }
6936
+ const statement = database.prepare(`
6937
+ insert into asset_attempts (
6938
+ id, project_id, node_asset_id, asset_id, attempt_index, source, prompt, generation_job_id,
6939
+ file_path, checksum_sha256, created_at, promoted_at, is_current
6940
+ ) values (?, ?, ?, ?, ?, 'reroll', ?, ?, ?, ?, ?, ?, ?)
6941
+ on conflict(id) do update set
6942
+ asset_id = excluded.asset_id,
6943
+ source = excluded.source,
6944
+ prompt = excluded.prompt,
6945
+ generation_job_id = excluded.generation_job_id,
6946
+ file_path = excluded.file_path,
6947
+ checksum_sha256 = excluded.checksum_sha256,
6948
+ promoted_at = excluded.promoted_at,
6949
+ is_current = excluded.is_current
6950
+ `);
6951
+ for (const attempt of attempts) {
6952
+ const generated = swissifierRerollAsset(attempt);
6953
+ statement.run(
6954
+ `${project}:${attempt.node_asset_id}:attempt:${attempt.attempt_index}`,
6955
+ project,
6956
+ attempt.node_asset_id,
6957
+ generated.assetId,
6958
+ attempt.attempt_index,
6959
+ attempt.prompt,
6960
+ attempt.generation_job_id,
6961
+ swissifierRerollRelativePath(manifest, attempt),
6962
+ generated.checksumSha256,
6963
+ timestamp,
6964
+ timestamp,
6965
+ attempt.current ? 1 : 0
6966
+ );
6967
+ }
6968
+ for (const nodeAssetId of nodes) {
6969
+ const currentCount = database.prepare("select count(*) count from asset_attempts where project_id = ? and node_asset_id = ? and is_current = 1").get(project, nodeAssetId);
6970
+ if (currentCount.count === 0) {
6971
+ database.prepare(`
6972
+ update asset_attempts
6973
+ set is_current = 1, promoted_at = ?
6974
+ where id = (
6975
+ select id from asset_attempts
6976
+ where project_id = ? and node_asset_id = ?
6977
+ order by attempt_index desc
6978
+ limit 1
6979
+ )
6980
+ `).run(timestamp, project, nodeAssetId);
6981
+ }
6982
+ }
6983
+ database.exec("COMMIT");
6984
+ } catch (error) {
6985
+ database.exec("ROLLBACK");
6986
+ throw error;
6987
+ }
5635
6988
  } finally {
5636
6989
  database.close();
5637
6990
  }
5638
- return { catalog: 0, local: manifest.assets.length, total: manifest.assets.length };
6991
+ return { total: attempts.length };
5639
6992
  }
5640
6993
  function seedDemoLineageWorkspace(project, fields) {
5641
6994
  const ids = fields.confirmWrite ? writeDemoFiles(project) : demoAssetIds();
@@ -5694,10 +7047,12 @@ function seedSwissifierRichDemoWorkspace(project, fields) {
5694
7047
  media_status: swissifierRichDemoMediaStatus(project)
5695
7048
  };
5696
7049
  }
7050
+ writeSwissifierRerollFiles(manifest);
5697
7051
  const summary = upsertSwissifierAssets(project, manifest);
5698
7052
  for (const edge of manifest.edges) {
5699
7053
  linkLineageAssets(project, { parentAssetId: edge.parent, childAssetId: edge.child, confirmWrite: true });
5700
7054
  }
7055
+ const reroll_attempts = upsertSwissifierRerollAttempts(project, manifest);
5701
7056
  updateSelectedAsset(project, {
5702
7057
  assetIds: manifest.selected_asset_ids,
5703
7058
  confirmWrite: true,
@@ -5725,6 +7080,7 @@ function seedSwissifierRichDemoWorkspace(project, fields) {
5725
7080
  media_status: swissifierRichDemoMediaStatus(project),
5726
7081
  root_asset_id: manifest.root_asset_id,
5727
7082
  selected_asset_ids: manifest.selected_asset_ids,
7083
+ reroll_attempts,
5728
7084
  summary,
5729
7085
  workspace
5730
7086
  };
@@ -5931,6 +7287,37 @@ app.get(
5931
7287
  })
5932
7288
  );
5933
7289
  registerLineageWorkspaceRoutes(app, projectFrom, asyncRoute);
7290
+ registerLineageTaskRoutes(app, projectFrom, asyncRoute);
7291
+ app.get("/api/lineage/:rootAssetId/rerolls", asyncRoute((req, res) => {
7292
+ res.json(listLineageRerollRequests(projectFrom(req), req.params.rootAssetId));
7293
+ }));
7294
+ app.post("/api/lineage/:rootAssetId/rerolls/:nodeAssetId", asyncRoute((req, res) => {
7295
+ res.json(markLineageRerollRequest(projectFrom(req), {
7296
+ rootAssetId: req.params.rootAssetId,
7297
+ nodeAssetId: req.params.nodeAssetId,
7298
+ notes: typeof req.body.notes === "string" ? req.body.notes : void 0,
7299
+ requestedBy: req.body.requestedBy === "agent" || req.body.requestedBy === "system" ? req.body.requestedBy : "human",
7300
+ confirmWrite: req.body.confirmWrite === true
7301
+ }));
7302
+ }));
7303
+ app.post("/api/lineage/:rootAssetId/rerolls/:nodeAssetId/cancel", asyncRoute((req, res) => {
7304
+ res.json(clearLineageRerollRequest(projectFrom(req), {
7305
+ rootAssetId: req.params.rootAssetId,
7306
+ nodeAssetId: req.params.nodeAssetId,
7307
+ confirmWrite: req.body.confirmWrite === true
7308
+ }));
7309
+ }));
7310
+ app.get("/api/lineage/:rootAssetId/attempts/:nodeAssetId", asyncRoute((req, res) => {
7311
+ res.json(getLineageAttempts(projectFrom(req), req.params.rootAssetId, req.params.nodeAssetId));
7312
+ }));
7313
+ app.post("/api/lineage/:rootAssetId/attempts/:nodeAssetId/promote", asyncRoute((req, res) => {
7314
+ res.json(promoteLineageAttempt(projectFrom(req), {
7315
+ rootAssetId: req.params.rootAssetId,
7316
+ nodeAssetId: req.params.nodeAssetId,
7317
+ attemptId: String(req.body.attemptId || ""),
7318
+ confirmWrite: req.body.confirmWrite === true
7319
+ }));
7320
+ }));
5934
7321
  app.get("/api/lineage/:assetId", asyncRoute((req, res) => {
5935
7322
  res.json(getLineageSnapshot(projectFrom(req), req.params.assetId));
5936
7323
  }));
@@ -6208,6 +7595,10 @@ app.use((error, _req, res, _next) => {
6208
7595
  res.status(error.status).json({ error: error.message });
6209
7596
  return;
6210
7597
  }
7598
+ if (isLineageTaskError(error)) {
7599
+ res.status(error.status).json({ error: error.message });
7600
+ return;
7601
+ }
6211
7602
  if (isLineageError(error)) {
6212
7603
  res.status(error.status).json({ error: error.message });
6213
7604
  return;