@mean-weasel/lineage 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +82 -1
- package/dist/cli/lineage-dev.js +739 -218
- package/dist/cli/lineage-dev.js.map +4 -4
- package/dist/cli/lineage.js +739 -218
- package/dist/cli/lineage.js.map +4 -4
- package/dist/server.js +1008 -82
- package/dist/server.js.map +4 -4
- package/dist/web/assets/index-DI7dn5M6.css +1 -0
- package/dist/web/assets/index-dJy71x2a.js +21 -0
- package/dist/web/index.html +2 -2
- package/fixtures/demo-project/lineage/swissifier-rich-demo.json +194 -0
- package/package.json +1 -1
- package/dist/web/assets/index-CegGQ4Cv.css +0 -1
- package/dist/web/assets/index-Cm95o4aV.js +0 -19
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/server.ts
|
|
2
2
|
import express2 from "express";
|
|
3
3
|
import multer from "multer";
|
|
4
|
-
import { existsSync as
|
|
4
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
5
5
|
import { join as join9 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// src/server/assetCore.ts
|
|
@@ -57,7 +57,7 @@ function walkLocalReviewFiles(dir, files = []) {
|
|
|
57
57
|
throw error;
|
|
58
58
|
}
|
|
59
59
|
for (const entry of entries) {
|
|
60
|
-
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "studio-uploads") continue;
|
|
60
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "studio-uploads" || entry.name === "playwright-results" || entry.name === "lineage-demo") continue;
|
|
61
61
|
if (process.env.NODE_ENV !== "test" && /^vitest-/.test(entry.name)) continue;
|
|
62
62
|
const path = join(dir, entry.name);
|
|
63
63
|
if (entry.isDirectory()) walkLocalReviewFiles(path, files);
|
|
@@ -595,6 +595,41 @@ function lineageDb() {
|
|
|
595
595
|
);
|
|
596
596
|
create index if not exists generation_job_receipts_job on generation_job_receipts(job_id, created_at);
|
|
597
597
|
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);
|
|
598
|
+
create table if not exists agent_claims (
|
|
599
|
+
id text primary key,
|
|
600
|
+
token_hash text not null,
|
|
601
|
+
project_id text not null references projects(id),
|
|
602
|
+
channel text,
|
|
603
|
+
scope_type text not null check (scope_type in ('lineage_workspace', 'content_post', 'content_queue_lane', 'selection_set', 'project_channel')),
|
|
604
|
+
target_id text not null,
|
|
605
|
+
target_title text,
|
|
606
|
+
agent_id text,
|
|
607
|
+
agent_name text not null,
|
|
608
|
+
agent_kind text not null,
|
|
609
|
+
thread_id text,
|
|
610
|
+
status text not null check (status in ('active', 'expired', 'released', 'revoked', 'transferred')),
|
|
611
|
+
created_at text not null,
|
|
612
|
+
heartbeat_at text not null,
|
|
613
|
+
expires_at text not null,
|
|
614
|
+
released_at text,
|
|
615
|
+
revoked_at text,
|
|
616
|
+
revoked_by text,
|
|
617
|
+
override_reason text,
|
|
618
|
+
metadata_json text
|
|
619
|
+
);
|
|
620
|
+
create unique index if not exists agent_claims_token_hash on agent_claims(token_hash);
|
|
621
|
+
create index if not exists agent_claims_project_status on agent_claims(project_id, status, heartbeat_at);
|
|
622
|
+
create index if not exists agent_claims_target on agent_claims(project_id, channel, scope_type, target_id, status);
|
|
623
|
+
create table if not exists agent_claim_events (
|
|
624
|
+
id text primary key,
|
|
625
|
+
claim_id text not null references agent_claims(id) on delete cascade,
|
|
626
|
+
event_type text not null,
|
|
627
|
+
actor text,
|
|
628
|
+
message text,
|
|
629
|
+
created_at text not null,
|
|
630
|
+
metadata_json text
|
|
631
|
+
);
|
|
632
|
+
create index if not exists agent_claim_events_claim_created on agent_claim_events(claim_id, created_at);
|
|
598
633
|
`);
|
|
599
634
|
migrateAssetSelections(database);
|
|
600
635
|
dropLegacyAssetSelectionRootUnique(database);
|
|
@@ -1930,6 +1965,379 @@ function updateAssetReview(project, fields) {
|
|
|
1930
1965
|
return { ok: true, message: `Marked ${fields.assetId} ${fields.reviewState}`, asset_id: fields.assetId, review_state: fields.reviewState };
|
|
1931
1966
|
}
|
|
1932
1967
|
|
|
1968
|
+
// src/server/agentClaims.ts
|
|
1969
|
+
import { createHash as createHash2, randomBytes, timingSafeEqual } from "node:crypto";
|
|
1970
|
+
var defaultTtlSeconds = 20 * 60;
|
|
1971
|
+
var idleAfterSeconds = 5 * 60;
|
|
1972
|
+
var staleAfterSeconds = 15 * 60;
|
|
1973
|
+
var scopes = /* @__PURE__ */ new Set(["lineage_workspace", "content_post", "content_queue_lane", "selection_set", "project_channel"]);
|
|
1974
|
+
var AgentClaimError = class extends Error {
|
|
1975
|
+
constructor(message, status = 400, code = "agent_claim_error", conflicts = []) {
|
|
1976
|
+
super(message);
|
|
1977
|
+
this.status = status;
|
|
1978
|
+
this.code = code;
|
|
1979
|
+
this.conflicts = conflicts;
|
|
1980
|
+
}
|
|
1981
|
+
status;
|
|
1982
|
+
code;
|
|
1983
|
+
conflicts;
|
|
1984
|
+
};
|
|
1985
|
+
function isAgentClaimError(error) {
|
|
1986
|
+
return error instanceof AgentClaimError;
|
|
1987
|
+
}
|
|
1988
|
+
function parseClaimTtl(value) {
|
|
1989
|
+
if (!value) return defaultTtlSeconds;
|
|
1990
|
+
const match = value.trim().match(/^(\d+)(s|m|h)?$/);
|
|
1991
|
+
if (!match) throw new AgentClaimError(`Invalid claim ttl: ${value}`);
|
|
1992
|
+
const amount = Number(match[1]);
|
|
1993
|
+
const unit = match[2] || "s";
|
|
1994
|
+
const multiplier = unit === "h" ? 3600 : unit === "m" ? 60 : 1;
|
|
1995
|
+
const seconds = amount * multiplier;
|
|
1996
|
+
if (!Number.isInteger(seconds) || seconds < 30 || seconds > 24 * 60 * 60) {
|
|
1997
|
+
throw new AgentClaimError("Claim ttl must be between 30 seconds and 24 hours");
|
|
1998
|
+
}
|
|
1999
|
+
return seconds;
|
|
2000
|
+
}
|
|
2001
|
+
function randomId(prefix) {
|
|
2002
|
+
return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString("base64url").toLowerCase()}`;
|
|
2003
|
+
}
|
|
2004
|
+
function tokenHash(token) {
|
|
2005
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
2006
|
+
}
|
|
2007
|
+
function safeEqual(a, b) {
|
|
2008
|
+
const left = Buffer.from(a);
|
|
2009
|
+
const right = Buffer.from(b);
|
|
2010
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
2011
|
+
}
|
|
2012
|
+
function expiresAtFrom(timestamp, ttlSeconds) {
|
|
2013
|
+
return new Date(new Date(timestamp).getTime() + ttlSeconds * 1e3).toISOString();
|
|
2014
|
+
}
|
|
2015
|
+
function metadataJson(metadata2) {
|
|
2016
|
+
return metadata2 ? JSON.stringify(metadata2) : null;
|
|
2017
|
+
}
|
|
2018
|
+
function parseMetadata(value) {
|
|
2019
|
+
if (typeof value !== "string" || !value) return void 0;
|
|
2020
|
+
try {
|
|
2021
|
+
const parsed = JSON.parse(value);
|
|
2022
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
2023
|
+
} catch {
|
|
2024
|
+
return void 0;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
function derivedState(row, now = /* @__PURE__ */ new Date()) {
|
|
2028
|
+
if (row.status !== "active") return row.status === "expired" ? "expired" : "stale";
|
|
2029
|
+
if (new Date(row.expires_at).getTime() <= now.getTime()) return "expired";
|
|
2030
|
+
const ageSeconds = Math.max(0, Math.floor((now.getTime() - new Date(row.heartbeat_at).getTime()) / 1e3));
|
|
2031
|
+
if (ageSeconds >= staleAfterSeconds) return "stale";
|
|
2032
|
+
if (ageSeconds >= idleAfterSeconds) return "idle";
|
|
2033
|
+
return "active";
|
|
2034
|
+
}
|
|
2035
|
+
function rowToClaim(row, now = /* @__PURE__ */ new Date()) {
|
|
2036
|
+
const heartbeatAt = String(row.heartbeat_at);
|
|
2037
|
+
return {
|
|
2038
|
+
id: String(row.id),
|
|
2039
|
+
project: String(row.project_id),
|
|
2040
|
+
channel: typeof row.channel === "string" ? row.channel : void 0,
|
|
2041
|
+
scope_type: String(row.scope_type),
|
|
2042
|
+
target_id: String(row.target_id),
|
|
2043
|
+
target_title: typeof row.target_title === "string" ? row.target_title : void 0,
|
|
2044
|
+
agent_id: typeof row.agent_id === "string" ? row.agent_id : void 0,
|
|
2045
|
+
agent_name: String(row.agent_name),
|
|
2046
|
+
agent_kind: String(row.agent_kind),
|
|
2047
|
+
thread_id: typeof row.thread_id === "string" ? row.thread_id : void 0,
|
|
2048
|
+
status: String(row.status),
|
|
2049
|
+
created_at: String(row.created_at),
|
|
2050
|
+
heartbeat_at: heartbeatAt,
|
|
2051
|
+
expires_at: String(row.expires_at),
|
|
2052
|
+
released_at: typeof row.released_at === "string" ? row.released_at : void 0,
|
|
2053
|
+
revoked_at: typeof row.revoked_at === "string" ? row.revoked_at : void 0,
|
|
2054
|
+
revoked_by: typeof row.revoked_by === "string" ? row.revoked_by : void 0,
|
|
2055
|
+
override_reason: typeof row.override_reason === "string" ? row.override_reason : void 0,
|
|
2056
|
+
metadata: parseMetadata(row.metadata_json),
|
|
2057
|
+
heartbeat_age_seconds: Math.max(0, Math.floor((now.getTime() - new Date(heartbeatAt).getTime()) / 1e3)),
|
|
2058
|
+
derived_state: derivedState({
|
|
2059
|
+
expires_at: String(row.expires_at),
|
|
2060
|
+
heartbeat_at: heartbeatAt,
|
|
2061
|
+
status: String(row.status)
|
|
2062
|
+
}, now)
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
function eventToRow(row) {
|
|
2066
|
+
return {
|
|
2067
|
+
claim_id: String(row.claim_id),
|
|
2068
|
+
event_type: String(row.event_type),
|
|
2069
|
+
actor: typeof row.actor === "string" ? row.actor : void 0,
|
|
2070
|
+
message: typeof row.message === "string" ? row.message : void 0,
|
|
2071
|
+
created_at: String(row.created_at),
|
|
2072
|
+
metadata: parseMetadata(row.metadata_json)
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
function ensureProject3(database, project) {
|
|
2076
|
+
const timestamp = nowIso();
|
|
2077
|
+
database.prepare(`
|
|
2078
|
+
insert into projects (id, product, created_at, updated_at)
|
|
2079
|
+
values (?, ?, ?, ?)
|
|
2080
|
+
on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
|
|
2081
|
+
`).run(project, project, timestamp, timestamp);
|
|
2082
|
+
}
|
|
2083
|
+
function recordEvent(database, claimId, eventType, actor, message, metadata2) {
|
|
2084
|
+
database.prepare(`
|
|
2085
|
+
insert into agent_claim_events (id, claim_id, event_type, actor, message, created_at, metadata_json)
|
|
2086
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
2087
|
+
`).run(randomId("claim_event"), claimId, eventType, actor || null, message || null, nowIso(), metadataJson(metadata2));
|
|
2088
|
+
}
|
|
2089
|
+
function expireActiveClaims(database) {
|
|
2090
|
+
const timestamp = nowIso();
|
|
2091
|
+
const expired = database.prepare(`
|
|
2092
|
+
select id from agent_claims where status = 'active' and expires_at <= ?
|
|
2093
|
+
`).all(timestamp);
|
|
2094
|
+
if (expired.length === 0) return;
|
|
2095
|
+
database.prepare(`
|
|
2096
|
+
update agent_claims set status = 'expired' where status = 'active' and expires_at <= ?
|
|
2097
|
+
`).run(timestamp);
|
|
2098
|
+
for (const claim of expired) recordEvent(database, claim.id, "expired", "system", "Claim expired after missed heartbeat.");
|
|
2099
|
+
}
|
|
2100
|
+
function normalizeScope(scopeType) {
|
|
2101
|
+
if (!scopes.has(scopeType)) throw new AgentClaimError(`Unsupported claim scope: ${scopeType}`);
|
|
2102
|
+
return scopeType;
|
|
2103
|
+
}
|
|
2104
|
+
function channelOverlaps(left, right) {
|
|
2105
|
+
return !left || !right || left === right;
|
|
2106
|
+
}
|
|
2107
|
+
function claimOverlaps(candidate, existing) {
|
|
2108
|
+
if (candidate.project !== existing.project) return false;
|
|
2109
|
+
if (!channelOverlaps(candidate.channel, existing.channel)) return false;
|
|
2110
|
+
if (candidate.scope_type === existing.scope_type && candidate.target_id === existing.target_id) return true;
|
|
2111
|
+
return candidate.scope_type === "project_channel" || existing.scope_type === "project_channel";
|
|
2112
|
+
}
|
|
2113
|
+
function activeClaims(database, project) {
|
|
2114
|
+
const rows = project ? database.prepare("select * from agent_claims where project_id = ? and status = 'active' order by heartbeat_at desc").all(project) : database.prepare("select * from agent_claims where status = 'active' order by project_id, heartbeat_at desc").all();
|
|
2115
|
+
return rows.map((row) => rowToClaim(row));
|
|
2116
|
+
}
|
|
2117
|
+
function findClaimById(database, claimId, project) {
|
|
2118
|
+
const row = project ? database.prepare("select * from agent_claims where project_id = ? and id = ?").get(project, claimId) : database.prepare("select * from agent_claims where id = ?").get(claimId);
|
|
2119
|
+
return row ? rowToClaim(row) : null;
|
|
2120
|
+
}
|
|
2121
|
+
function findClaimRowByToken(database, claimToken) {
|
|
2122
|
+
const claimId = claimToken.split(".")[0];
|
|
2123
|
+
const row = database.prepare("select * from agent_claims where id = ?").get(claimId);
|
|
2124
|
+
if (!row) return null;
|
|
2125
|
+
return safeEqual(String(row.token_hash), tokenHash(claimToken)) ? row : null;
|
|
2126
|
+
}
|
|
2127
|
+
function denied(code, message, conflicts = []) {
|
|
2128
|
+
return { ok: false, code, message, conflicts };
|
|
2129
|
+
}
|
|
2130
|
+
function scopeAllowsWrite(claim, scopeType, targetId, writeKind) {
|
|
2131
|
+
if (claim.scope_type === scopeType && claim.target_id === targetId) return true;
|
|
2132
|
+
if (claim.scope_type === "project_channel") return true;
|
|
2133
|
+
if (claim.scope_type === "lineage_workspace" && scopeType === "lineage_workspace") return claim.target_id === targetId;
|
|
2134
|
+
if (claim.scope_type === "content_queue_lane" && writeKind === "content_queue_next") return true;
|
|
2135
|
+
return false;
|
|
2136
|
+
}
|
|
2137
|
+
function createAgentClaim(fields) {
|
|
2138
|
+
const project = fields.project.trim();
|
|
2139
|
+
const targetId = fields.targetId.trim();
|
|
2140
|
+
const agentName = fields.agentName.trim();
|
|
2141
|
+
if (!project) throw new AgentClaimError("Agent claim requires project");
|
|
2142
|
+
if (!targetId) throw new AgentClaimError("Agent claim requires target");
|
|
2143
|
+
if (!agentName) throw new AgentClaimError("Agent claim requires agent name");
|
|
2144
|
+
const scopeType = normalizeScope(fields.scopeType);
|
|
2145
|
+
const ttlSeconds = fields.ttlSeconds || defaultTtlSeconds;
|
|
2146
|
+
const database = lineageDb();
|
|
2147
|
+
try {
|
|
2148
|
+
ensureProject3(database, project);
|
|
2149
|
+
expireActiveClaims(database);
|
|
2150
|
+
const candidate = { project, channel: fields.channel?.trim() || void 0, scope_type: scopeType, target_id: targetId };
|
|
2151
|
+
const conflicts = activeClaims(database, project).filter((claim2) => claimOverlaps(candidate, claim2));
|
|
2152
|
+
if (conflicts.length > 0 && !fields.force) {
|
|
2153
|
+
throw new AgentClaimError("Target already has an active overlapping agent claim.", 409, "target_already_claimed", conflicts);
|
|
2154
|
+
}
|
|
2155
|
+
if (conflicts.length > 0 && !fields.reason?.trim()) {
|
|
2156
|
+
throw new AgentClaimError("Overriding an active claim requires --reason.", 400, "override_reason_required", conflicts);
|
|
2157
|
+
}
|
|
2158
|
+
const timestamp = nowIso();
|
|
2159
|
+
for (const conflict of conflicts) {
|
|
2160
|
+
database.prepare(`
|
|
2161
|
+
update agent_claims
|
|
2162
|
+
set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
|
|
2163
|
+
where id = ? and status = 'active'
|
|
2164
|
+
`).run(timestamp, agentName, fields.reason || null, conflict.id);
|
|
2165
|
+
recordEvent(database, conflict.id, "revoked", agentName, fields.reason || "Revoked by forced claim takeover.");
|
|
2166
|
+
recordEvent(database, conflict.id, "conflict", agentName, `Overridden by ${agentName}.`, { new_claim_target: targetId });
|
|
2167
|
+
}
|
|
2168
|
+
const id = randomId("claim");
|
|
2169
|
+
const secret = randomBytes(24).toString("base64url");
|
|
2170
|
+
const claimToken = `${id}.${secret}`;
|
|
2171
|
+
const expiresAt = expiresAtFrom(timestamp, ttlSeconds);
|
|
2172
|
+
database.prepare(`
|
|
2173
|
+
insert into agent_claims (
|
|
2174
|
+
id, token_hash, project_id, channel, scope_type, target_id, target_title, agent_id, agent_name,
|
|
2175
|
+
agent_kind, thread_id, status, created_at, heartbeat_at, expires_at, metadata_json
|
|
2176
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
|
|
2177
|
+
`).run(
|
|
2178
|
+
id,
|
|
2179
|
+
tokenHash(claimToken),
|
|
2180
|
+
project,
|
|
2181
|
+
candidate.channel || null,
|
|
2182
|
+
scopeType,
|
|
2183
|
+
targetId,
|
|
2184
|
+
fields.targetTitle?.trim() || null,
|
|
2185
|
+
fields.agentId?.trim() || null,
|
|
2186
|
+
agentName,
|
|
2187
|
+
fields.agentKind?.trim() || "codex",
|
|
2188
|
+
fields.threadId?.trim() || null,
|
|
2189
|
+
timestamp,
|
|
2190
|
+
timestamp,
|
|
2191
|
+
expiresAt,
|
|
2192
|
+
metadataJson(fields.metadata)
|
|
2193
|
+
);
|
|
2194
|
+
recordEvent(database, id, "created", agentName, `Claimed ${scopeType} ${targetId}.`, { ttl_seconds: ttlSeconds });
|
|
2195
|
+
const claim = findClaimById(database, id);
|
|
2196
|
+
return { ok: true, claim, claim_token: claimToken, conflicts_revoked: conflicts.map((conflict) => conflict.id) };
|
|
2197
|
+
} finally {
|
|
2198
|
+
database.close();
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
function listAgentClaims(project) {
|
|
2202
|
+
const database = lineageDb();
|
|
2203
|
+
try {
|
|
2204
|
+
expireActiveClaims(database);
|
|
2205
|
+
const rows = project ? database.prepare("select * from agent_claims where project_id = ? order by status, heartbeat_at desc").all(project) : database.prepare("select * from agent_claims order by project_id, status, heartbeat_at desc").all();
|
|
2206
|
+
return { ok: true, claims: rows.map((row) => rowToClaim(row)), fetchedAt: nowIso() };
|
|
2207
|
+
} finally {
|
|
2208
|
+
database.close();
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
function inspectAgentClaim(claimId, project) {
|
|
2212
|
+
const database = lineageDb();
|
|
2213
|
+
try {
|
|
2214
|
+
expireActiveClaims(database);
|
|
2215
|
+
const claim = findClaimById(database, claimId, project);
|
|
2216
|
+
if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
|
|
2217
|
+
const events = database.prepare("select * from agent_claim_events where claim_id = ? order by created_at").all(claim.id);
|
|
2218
|
+
return { ok: true, claim, events: events.map(eventToRow) };
|
|
2219
|
+
} finally {
|
|
2220
|
+
database.close();
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
function heartbeatAgentClaim(claimToken, ttlSeconds = defaultTtlSeconds) {
|
|
2224
|
+
const database = lineageDb();
|
|
2225
|
+
try {
|
|
2226
|
+
expireActiveClaims(database);
|
|
2227
|
+
const row = findClaimRowByToken(database, claimToken);
|
|
2228
|
+
if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
|
|
2229
|
+
const claim = rowToClaim(row);
|
|
2230
|
+
if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
|
|
2231
|
+
const timestamp = nowIso();
|
|
2232
|
+
database.prepare("update agent_claims set heartbeat_at = ?, expires_at = ? where id = ?").run(timestamp, expiresAtFrom(timestamp, ttlSeconds), claim.id);
|
|
2233
|
+
recordEvent(database, claim.id, "heartbeat", claim.agent_name, "Claim heartbeat received.");
|
|
2234
|
+
return { ok: true, claim: findClaimById(database, claim.id) };
|
|
2235
|
+
} finally {
|
|
2236
|
+
database.close();
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
function releaseAgentClaim(claimToken) {
|
|
2240
|
+
const database = lineageDb();
|
|
2241
|
+
try {
|
|
2242
|
+
expireActiveClaims(database);
|
|
2243
|
+
const row = findClaimRowByToken(database, claimToken);
|
|
2244
|
+
if (!row) throw new AgentClaimError("Unknown or invalid agent claim token.", 401, "claim_token_invalid");
|
|
2245
|
+
const claim = rowToClaim(row);
|
|
2246
|
+
if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
|
|
2247
|
+
const timestamp = nowIso();
|
|
2248
|
+
database.prepare("update agent_claims set status = 'released', released_at = ? where id = ?").run(timestamp, claim.id);
|
|
2249
|
+
recordEvent(database, claim.id, "released", claim.agent_name, "Claim released by token holder.");
|
|
2250
|
+
return { ok: true, claim: findClaimById(database, claim.id) };
|
|
2251
|
+
} finally {
|
|
2252
|
+
database.close();
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
function releaseStaleAgentClaim(project, claimId, fields) {
|
|
2256
|
+
if (!fields.confirmWrite) throw new AgentClaimError("Releasing a stale agent claim requires confirmWrite=true.", 400, "confirm_write_required");
|
|
2257
|
+
if (!fields.reason?.trim()) throw new AgentClaimError("Releasing a stale agent claim requires a reason.", 400, "reason_required");
|
|
2258
|
+
const database = lineageDb();
|
|
2259
|
+
try {
|
|
2260
|
+
expireActiveClaims(database);
|
|
2261
|
+
const claim = findClaimById(database, claimId, project);
|
|
2262
|
+
if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
|
|
2263
|
+
if (claim.status !== "active" || claim.derived_state !== "stale") {
|
|
2264
|
+
throw new AgentClaimError("Only stale active claims can be released without the claim token.", 409, "claim_not_stale", [claim]);
|
|
2265
|
+
}
|
|
2266
|
+
const timestamp = nowIso();
|
|
2267
|
+
database.prepare("update agent_claims set status = 'released', released_at = ?, revoked_by = ?, override_reason = ? where id = ?").run(timestamp, fields.actor || "human", fields.reason, claim.id);
|
|
2268
|
+
recordEvent(database, claim.id, "released", fields.actor || "human", fields.reason);
|
|
2269
|
+
return { ok: true, claim: findClaimById(database, claim.id) };
|
|
2270
|
+
} finally {
|
|
2271
|
+
database.close();
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
function revokeAgentClaim(project, claimId, fields) {
|
|
2275
|
+
if (!fields.confirmWrite) throw new AgentClaimError("Revoking an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
|
|
2276
|
+
if (!fields.reason?.trim()) throw new AgentClaimError("Revoking an agent claim requires a reason.", 400, "reason_required");
|
|
2277
|
+
const database = lineageDb();
|
|
2278
|
+
try {
|
|
2279
|
+
expireActiveClaims(database);
|
|
2280
|
+
const claim = findClaimById(database, claimId, project);
|
|
2281
|
+
if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
|
|
2282
|
+
const timestamp = nowIso();
|
|
2283
|
+
database.prepare(`
|
|
2284
|
+
update agent_claims set status = 'revoked', revoked_at = ?, revoked_by = ?, override_reason = ?
|
|
2285
|
+
where id = ?
|
|
2286
|
+
`).run(timestamp, fields.actor || "human", fields.reason, claim.id);
|
|
2287
|
+
recordEvent(database, claim.id, "revoked", fields.actor || "human", fields.reason);
|
|
2288
|
+
return { ok: true, claim: findClaimById(database, claim.id) };
|
|
2289
|
+
} finally {
|
|
2290
|
+
database.close();
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
function transferAgentClaim(project, claimId, fields) {
|
|
2294
|
+
if (!fields.confirmWrite) throw new AgentClaimError("Transferring an agent claim requires confirmWrite=true.", 400, "confirm_write_required");
|
|
2295
|
+
const toAgentName = fields.toAgentName.trim();
|
|
2296
|
+
if (!toAgentName) throw new AgentClaimError("Transfer requires toAgentName.", 400, "agent_name_required");
|
|
2297
|
+
const database = lineageDb();
|
|
2298
|
+
try {
|
|
2299
|
+
expireActiveClaims(database);
|
|
2300
|
+
const claim = findClaimById(database, claimId, project);
|
|
2301
|
+
if (!claim) throw new AgentClaimError(`Unknown agent claim: ${claimId}`, 404, "claim_not_found");
|
|
2302
|
+
if (claim.status !== "active") throw new AgentClaimError(`Agent claim is ${claim.status}.`, 409, "claim_not_active");
|
|
2303
|
+
database.prepare("update agent_claims set agent_name = ? where id = ?").run(toAgentName, claim.id);
|
|
2304
|
+
recordEvent(database, claim.id, "transferred", fields.actor || "human", fields.reason || `Transferred claim to ${toAgentName}.`, { to_agent_name: toAgentName });
|
|
2305
|
+
return { ok: true, claim: findClaimById(database, claim.id) };
|
|
2306
|
+
} finally {
|
|
2307
|
+
database.close();
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
function validateAgentClaimForWrite(fields) {
|
|
2311
|
+
if (fields.dangerLevel === "danger" && !fields.confirmWrite) {
|
|
2312
|
+
return denied("human_confirmation_required", "Dangerous write requires explicit human confirmation.");
|
|
2313
|
+
}
|
|
2314
|
+
if (!fields.claimToken) return denied("claim_required", "Mutating agent write requires a matching claim token.");
|
|
2315
|
+
const database = lineageDb();
|
|
2316
|
+
try {
|
|
2317
|
+
expireActiveClaims(database);
|
|
2318
|
+
const row = findClaimRowByToken(database, fields.claimToken);
|
|
2319
|
+
if (!row) return denied("claim_token_invalid", "Unknown or invalid claim token.");
|
|
2320
|
+
const claim = rowToClaim(row);
|
|
2321
|
+
if (claim.status !== "active") return denied("claim_not_active", `Agent claim is ${claim.status}.`);
|
|
2322
|
+
if (new Date(claim.expires_at).getTime() <= Date.now()) return denied("claim_expired", "Agent claim has expired.");
|
|
2323
|
+
if (claim.project !== fields.project) return denied("claim_project_mismatch", `Claim project ${claim.project} does not match ${fields.project}.`, [claim]);
|
|
2324
|
+
if (fields.channel && claim.channel && claim.channel !== fields.channel) {
|
|
2325
|
+
return denied("claim_channel_mismatch", `Claim channel ${claim.channel} does not match ${fields.channel}.`, [claim]);
|
|
2326
|
+
}
|
|
2327
|
+
if (!scopeAllowsWrite(claim, fields.scopeType, fields.targetId, fields.writeKind)) {
|
|
2328
|
+
return denied("claim_scope_mismatch", `Claim does not cover ${fields.scopeType} ${fields.targetId}.`, [claim]);
|
|
2329
|
+
}
|
|
2330
|
+
recordEvent(database, claim.id, "write_allowed", claim.agent_name, `${fields.writeKind} allowed.`, {
|
|
2331
|
+
danger_level: fields.dangerLevel,
|
|
2332
|
+
target_id: fields.targetId,
|
|
2333
|
+
write_kind: fields.writeKind
|
|
2334
|
+
});
|
|
2335
|
+
return { ok: true, claim, warnings: [] };
|
|
2336
|
+
} finally {
|
|
2337
|
+
database.close();
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
1933
2341
|
// src/server/assetLineageHandoff.ts
|
|
1934
2342
|
var publicPackageCommand = "npx @mean-weasel/lineage";
|
|
1935
2343
|
function shellQuote(value) {
|
|
@@ -1987,9 +2395,23 @@ function getLineageBrief(project, rootAssetId) {
|
|
|
1987
2395
|
function linkSelectedLineageChild(project, fields) {
|
|
1988
2396
|
const next = getLineageNextAsset(project, fields.rootAssetId);
|
|
1989
2397
|
if (!next.next_asset) throw new LineageError("Cannot link child until a next base is selected or unambiguous");
|
|
2398
|
+
if (fields.confirmWrite) {
|
|
2399
|
+
const validation = validateAgentClaimForWrite({
|
|
2400
|
+
channel: next.next_asset.channel,
|
|
2401
|
+
claimToken: fields.claimToken,
|
|
2402
|
+
confirmWrite: fields.confirmWrite,
|
|
2403
|
+
dangerLevel: "enforce",
|
|
2404
|
+
project,
|
|
2405
|
+
scopeType: "lineage_workspace",
|
|
2406
|
+
targetId: lineageWorkspaceId(project, next.root_asset_id),
|
|
2407
|
+
writeKind: "link_child"
|
|
2408
|
+
});
|
|
2409
|
+
if (!validation.ok) throw new AgentClaimError(validation.message, validation.code === "claim_required" ? 401 : 403, validation.code, validation.conflicts);
|
|
2410
|
+
}
|
|
1990
2411
|
const result = linkLineageAssets(project, {
|
|
1991
2412
|
childAssetId: fields.childAssetId,
|
|
1992
2413
|
confirmWrite: fields.confirmWrite,
|
|
2414
|
+
claimToken: fields.claimToken,
|
|
1993
2415
|
parentAssetId: next.next_asset.asset_id
|
|
1994
2416
|
});
|
|
1995
2417
|
return {
|
|
@@ -2867,7 +3289,7 @@ var AssetSelectionError = class extends Error {
|
|
|
2867
3289
|
function isAssetSelectionError(error) {
|
|
2868
3290
|
return error instanceof AssetSelectionError;
|
|
2869
3291
|
}
|
|
2870
|
-
function
|
|
3292
|
+
function ensureProject4(database, project) {
|
|
2871
3293
|
const timestamp = nowIso();
|
|
2872
3294
|
database.prepare(`
|
|
2873
3295
|
insert into projects (id, product, created_at, updated_at)
|
|
@@ -2953,7 +3375,7 @@ function getCurrentSet(database, project) {
|
|
|
2953
3375
|
return found;
|
|
2954
3376
|
}
|
|
2955
3377
|
function ensureSelectionSet(database, project, fields) {
|
|
2956
|
-
|
|
3378
|
+
ensureProject4(database, project);
|
|
2957
3379
|
const timestamp = nowIso();
|
|
2958
3380
|
const id = setId(project, fields.kind, fields.key);
|
|
2959
3381
|
database.prepare(`
|
|
@@ -3183,6 +3605,78 @@ function assetSelectionRouter(projectFrom2) {
|
|
|
3183
3605
|
return router;
|
|
3184
3606
|
}
|
|
3185
3607
|
|
|
3608
|
+
// src/server/agentClaimRoutes.ts
|
|
3609
|
+
function claimTokenFromRequest(req) {
|
|
3610
|
+
const header = req.header("X-Lineage-Claim-Token");
|
|
3611
|
+
if (header) return header;
|
|
3612
|
+
return typeof req.body?.claimToken === "string" ? req.body.claimToken : void 0;
|
|
3613
|
+
}
|
|
3614
|
+
function stringBody(req, key) {
|
|
3615
|
+
const value = req.body[key];
|
|
3616
|
+
return typeof value === "string" ? value : void 0;
|
|
3617
|
+
}
|
|
3618
|
+
function boolBody(req, key) {
|
|
3619
|
+
return req.body[key] === true;
|
|
3620
|
+
}
|
|
3621
|
+
function registerAgentClaimRoutes(app2, projectFrom2, asyncRoute2) {
|
|
3622
|
+
app2.get("/api/agent-claims", asyncRoute2((req, res) => {
|
|
3623
|
+
const project = typeof req.query.project === "string" ? req.query.project : void 0;
|
|
3624
|
+
res.json(listAgentClaims(project));
|
|
3625
|
+
}));
|
|
3626
|
+
app2.post("/api/agent-claims", asyncRoute2((req, res) => {
|
|
3627
|
+
res.json(createAgentClaim({
|
|
3628
|
+
agentId: stringBody(req, "agentId"),
|
|
3629
|
+
agentKind: stringBody(req, "agentKind"),
|
|
3630
|
+
agentName: stringBody(req, "agentName") || stringBody(req, "agent_name") || "",
|
|
3631
|
+
channel: stringBody(req, "channel"),
|
|
3632
|
+
force: boolBody(req, "force"),
|
|
3633
|
+
metadata: typeof req.body.metadata === "object" && req.body.metadata !== null ? req.body.metadata : void 0,
|
|
3634
|
+
project: projectFrom2({ body: req.body, query: req.query }),
|
|
3635
|
+
reason: stringBody(req, "reason"),
|
|
3636
|
+
scopeType: stringBody(req, "scopeType") || stringBody(req, "scope_type") || "",
|
|
3637
|
+
targetId: stringBody(req, "targetId") || stringBody(req, "target_id") || "",
|
|
3638
|
+
targetTitle: stringBody(req, "targetTitle") || stringBody(req, "target_title"),
|
|
3639
|
+
threadId: stringBody(req, "threadId") || stringBody(req, "thread_id"),
|
|
3640
|
+
ttlSeconds: parseClaimTtl(stringBody(req, "ttl"))
|
|
3641
|
+
}));
|
|
3642
|
+
}));
|
|
3643
|
+
app2.get("/api/agent-claims/:claimId", asyncRoute2((req, res) => {
|
|
3644
|
+
res.json(inspectAgentClaim(req.params.claimId, typeof req.query.project === "string" ? req.query.project : void 0));
|
|
3645
|
+
}));
|
|
3646
|
+
app2.post("/api/agent-claims/:claimId/heartbeat", asyncRoute2((req, res) => {
|
|
3647
|
+
const claimToken = claimTokenFromRequest(req);
|
|
3648
|
+
if (!claimToken) throw new AgentClaimError("Heartbeat requires claimToken", 400, "claim_token_required");
|
|
3649
|
+
res.json(heartbeatAgentClaim(claimToken, parseClaimTtl(stringBody(req, "ttl"))));
|
|
3650
|
+
}));
|
|
3651
|
+
app2.post("/api/agent-claims/:claimId/release", asyncRoute2((req, res) => {
|
|
3652
|
+
const claimToken = claimTokenFromRequest(req);
|
|
3653
|
+
if (!claimToken) throw new AgentClaimError("Release requires claimToken", 400, "claim_token_required");
|
|
3654
|
+
res.json(releaseAgentClaim(claimToken));
|
|
3655
|
+
}));
|
|
3656
|
+
app2.post("/api/agent-claims/:claimId/release-stale", asyncRoute2((req, res) => {
|
|
3657
|
+
res.json(releaseStaleAgentClaim(projectFrom2({ body: req.body, query: req.query }), req.params.claimId, {
|
|
3658
|
+
actor: stringBody(req, "actor") || "human",
|
|
3659
|
+
confirmWrite: boolBody(req, "confirmWrite"),
|
|
3660
|
+
reason: stringBody(req, "reason")
|
|
3661
|
+
}));
|
|
3662
|
+
}));
|
|
3663
|
+
app2.post("/api/agent-claims/:claimId/revoke", asyncRoute2((req, res) => {
|
|
3664
|
+
res.json(revokeAgentClaim(projectFrom2({ body: req.body, query: req.query }), req.params.claimId, {
|
|
3665
|
+
actor: stringBody(req, "actor") || "human",
|
|
3666
|
+
confirmWrite: boolBody(req, "confirmWrite"),
|
|
3667
|
+
reason: stringBody(req, "reason")
|
|
3668
|
+
}));
|
|
3669
|
+
}));
|
|
3670
|
+
app2.post("/api/agent-claims/:claimId/transfer", asyncRoute2((req, res) => {
|
|
3671
|
+
res.json(transferAgentClaim(projectFrom2({ body: req.body, query: req.query }), req.params.claimId, {
|
|
3672
|
+
actor: stringBody(req, "actor") || "human",
|
|
3673
|
+
confirmWrite: boolBody(req, "confirmWrite"),
|
|
3674
|
+
reason: stringBody(req, "reason"),
|
|
3675
|
+
toAgentName: stringBody(req, "toAgentName") || stringBody(req, "to_agent_name") || ""
|
|
3676
|
+
}));
|
|
3677
|
+
}));
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3186
3680
|
// src/server/adapters/adapterSettings.ts
|
|
3187
3681
|
var AdapterSettingsError = class extends Error {
|
|
3188
3682
|
constructor(message, status = 400) {
|
|
@@ -3227,7 +3721,7 @@ var definitions = [
|
|
|
3227
3721
|
secret_ref: null
|
|
3228
3722
|
}
|
|
3229
3723
|
];
|
|
3230
|
-
function
|
|
3724
|
+
function ensureProject5(database, project) {
|
|
3231
3725
|
const timestamp = nowIso();
|
|
3232
3726
|
database.prepare(`
|
|
3233
3727
|
insert into projects (id, product, created_at, updated_at)
|
|
@@ -3241,7 +3735,7 @@ function definitionFor(adapterType, provider) {
|
|
|
3241
3735
|
return definition;
|
|
3242
3736
|
}
|
|
3243
3737
|
function seedDefaults(database, project) {
|
|
3244
|
-
|
|
3738
|
+
ensureProject5(database, project);
|
|
3245
3739
|
const timestamp = nowIso();
|
|
3246
3740
|
for (const definition of definitions) {
|
|
3247
3741
|
database.prepare(`
|
|
@@ -3500,7 +3994,7 @@ var ContentBatchError = class extends Error {
|
|
|
3500
3994
|
function isContentBatchError(error) {
|
|
3501
3995
|
return error instanceof ContentBatchError;
|
|
3502
3996
|
}
|
|
3503
|
-
function
|
|
3997
|
+
function ensureProject6(database, project) {
|
|
3504
3998
|
const timestamp = nowIso();
|
|
3505
3999
|
database.prepare(`
|
|
3506
4000
|
insert into projects (id, product, created_at, updated_at)
|
|
@@ -3628,7 +4122,7 @@ function createContentBatch(project, fields) {
|
|
|
3628
4122
|
const database = lineageDb();
|
|
3629
4123
|
const timestamp = nowIso();
|
|
3630
4124
|
try {
|
|
3631
|
-
|
|
4125
|
+
ensureProject6(database, project);
|
|
3632
4126
|
database.prepare(`
|
|
3633
4127
|
insert into content_batches (id, project_id, title, campaign, channel, status, notes, created_at, updated_at)
|
|
3634
4128
|
values (?, ?, ?, ?, ?, 'active', ?, ?, ?)
|
|
@@ -3650,7 +4144,7 @@ function createContentPost(project, fields) {
|
|
|
3650
4144
|
const database = lineageDb();
|
|
3651
4145
|
const timestamp = nowIso();
|
|
3652
4146
|
try {
|
|
3653
|
-
|
|
4147
|
+
ensureProject6(database, project);
|
|
3654
4148
|
const batch = database.prepare("select id from content_batches where project_id = ? and id = ?").get(project, batchId);
|
|
3655
4149
|
if (!batch) throw new ContentBatchError(`Unknown content batch: ${batchId}`, 404);
|
|
3656
4150
|
database.prepare(`
|
|
@@ -3675,6 +4169,30 @@ function findContentPost(database, project, postId) {
|
|
|
3675
4169
|
if (!row) throw new ContentBatchError(`Unknown content post: ${postId}`, 404);
|
|
3676
4170
|
return postFromRow(row, assetsForPost(database, project, postId));
|
|
3677
4171
|
}
|
|
4172
|
+
function requireContentPostClaim(project, post, claimToken, writeKind) {
|
|
4173
|
+
if (!claimToken && !hasActiveContentPostClaim(project, post)) return;
|
|
4174
|
+
const validation = validateAgentClaimForWrite({
|
|
4175
|
+
channel: post.channel,
|
|
4176
|
+
claimToken,
|
|
4177
|
+
confirmWrite: true,
|
|
4178
|
+
dangerLevel: "enforce",
|
|
4179
|
+
project,
|
|
4180
|
+
scopeType: "content_post",
|
|
4181
|
+
targetId: post.id,
|
|
4182
|
+
writeKind
|
|
4183
|
+
});
|
|
4184
|
+
if (!validation.ok) {
|
|
4185
|
+
const status = validation.code === "claim_required" || validation.code === "claim_token_invalid" ? 401 : 409;
|
|
4186
|
+
throw new AgentClaimError(validation.message, status, validation.code, validation.conflicts);
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
function hasActiveContentPostClaim(project, post) {
|
|
4190
|
+
return listAgentClaims(project).claims.some((claim) => {
|
|
4191
|
+
if (claim.project !== project || claim.status !== "active" || claim.derived_state === "expired") return false;
|
|
4192
|
+
if (claim.scope_type === "content_post") return claim.target_id === post.id;
|
|
4193
|
+
return claim.scope_type === "project_channel" && (!claim.channel || claim.channel === post.channel);
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
3678
4196
|
function listContentPosts(project, filters = {}) {
|
|
3679
4197
|
const database = lineageDb();
|
|
3680
4198
|
try {
|
|
@@ -3697,6 +4215,7 @@ function updateContentPost(project, fields) {
|
|
|
3697
4215
|
const timestamp = nowIso();
|
|
3698
4216
|
try {
|
|
3699
4217
|
const current = findContentPost(database, project, postId);
|
|
4218
|
+
if (fields.phase) requireContentPostClaim(project, current, fields.claimToken, "content_post_phase");
|
|
3700
4219
|
if (fields.batchId) {
|
|
3701
4220
|
const batch = database.prepare("select id from content_batches where project_id = ? and id = ?").get(project, fields.batchId);
|
|
3702
4221
|
if (!batch) throw new ContentBatchError(`Unknown content batch: ${fields.batchId}`, 404);
|
|
@@ -3734,7 +4253,8 @@ function attachContentPostAsset(project, fields) {
|
|
|
3734
4253
|
const database = lineageDb();
|
|
3735
4254
|
const timestamp = nowIso();
|
|
3736
4255
|
try {
|
|
3737
|
-
findContentPost(database, project, postId);
|
|
4256
|
+
const current = findContentPost(database, project, postId);
|
|
4257
|
+
requireContentPostClaim(project, current, fields.claimToken, "content_post_attach_asset");
|
|
3738
4258
|
database.prepare(`
|
|
3739
4259
|
insert into content_post_assets (id, project_id, post_id, asset_id, role, notes, attached_at)
|
|
3740
4260
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -3752,6 +4272,8 @@ function detachContentPostAsset(project, fields) {
|
|
|
3752
4272
|
if (!fields.confirmWrite) return { ok: true, dryRun: true, message: `Would detach ${assetId} from ${postId}`, preview: { postId, assetId, role } };
|
|
3753
4273
|
const database = lineageDb();
|
|
3754
4274
|
try {
|
|
4275
|
+
const current = findContentPost(database, project, postId);
|
|
4276
|
+
requireContentPostClaim(project, current, fields.claimToken, "content_post_detach_asset");
|
|
3755
4277
|
database.prepare("delete from content_post_assets where project_id = ? and post_id = ? and asset_id = ? and role = ?").run(project, postId, assetId, role);
|
|
3756
4278
|
return { ok: true, message: `Detached ${assetId} from ${postId}`, post: findContentPost(database, project, postId) };
|
|
3757
4279
|
} finally {
|
|
@@ -4302,13 +4824,19 @@ function getContentOpsQueue(project) {
|
|
|
4302
4824
|
}
|
|
4303
4825
|
|
|
4304
4826
|
// src/server/contentBatchRoutes.ts
|
|
4305
|
-
function
|
|
4827
|
+
function stringBody2(req, key) {
|
|
4306
4828
|
const value = req.body[key];
|
|
4307
4829
|
return typeof value === "string" ? value : void 0;
|
|
4308
4830
|
}
|
|
4309
|
-
function
|
|
4831
|
+
function boolBody2(req, key) {
|
|
4310
4832
|
return req.body[key] === true;
|
|
4311
4833
|
}
|
|
4834
|
+
function claimTokenFromRequest2(req) {
|
|
4835
|
+
const header = req.header("X-Lineage-Claim-Token");
|
|
4836
|
+
if (header) return header;
|
|
4837
|
+
const value = req.body.claimToken;
|
|
4838
|
+
return typeof value === "string" ? value : void 0;
|
|
4839
|
+
}
|
|
4312
4840
|
function bodyProjectShape(req) {
|
|
4313
4841
|
return { body: req.body, query: req.query };
|
|
4314
4842
|
}
|
|
@@ -4319,22 +4847,22 @@ function contentBatchRouter(projectFrom2) {
|
|
|
4319
4847
|
});
|
|
4320
4848
|
router.post("/batches", (req, res) => {
|
|
4321
4849
|
res.json(createContentBatch(projectFrom2(bodyProjectShape(req)), {
|
|
4322
|
-
batchId:
|
|
4323
|
-
campaign:
|
|
4324
|
-
channel:
|
|
4325
|
-
confirmWrite:
|
|
4326
|
-
notes:
|
|
4327
|
-
title:
|
|
4850
|
+
batchId: stringBody2(req, "batchId") || stringBody2(req, "id") || "",
|
|
4851
|
+
campaign: stringBody2(req, "campaign"),
|
|
4852
|
+
channel: stringBody2(req, "channel"),
|
|
4853
|
+
confirmWrite: boolBody2(req, "confirmWrite"),
|
|
4854
|
+
notes: stringBody2(req, "notes"),
|
|
4855
|
+
title: stringBody2(req, "title") || ""
|
|
4328
4856
|
}));
|
|
4329
4857
|
});
|
|
4330
4858
|
router.post("/import/demo", (req, res) => {
|
|
4331
|
-
const kind =
|
|
4859
|
+
const kind = stringBody2(req, "kind");
|
|
4332
4860
|
res.json(importDemoContentBatch(projectFrom2(bodyProjectShape(req)), {
|
|
4333
|
-
batchId:
|
|
4334
|
-
campaign:
|
|
4335
|
-
confirmWrite:
|
|
4861
|
+
batchId: stringBody2(req, "batchId") || "",
|
|
4862
|
+
campaign: stringBody2(req, "campaign"),
|
|
4863
|
+
confirmWrite: boolBody2(req, "confirmWrite"),
|
|
4336
4864
|
kind: kind === "concepts" || kind === "drafts" || kind === "all" ? kind : "all",
|
|
4337
|
-
title:
|
|
4865
|
+
title: stringBody2(req, "title")
|
|
4338
4866
|
}));
|
|
4339
4867
|
});
|
|
4340
4868
|
router.get("/target", (req, res) => {
|
|
@@ -4345,13 +4873,13 @@ function contentBatchRouter(projectFrom2) {
|
|
|
4345
4873
|
});
|
|
4346
4874
|
router.post("/target", (req, res) => {
|
|
4347
4875
|
res.json(setContentTarget(projectFrom2(bodyProjectShape(req)), {
|
|
4348
|
-
confirmWrite:
|
|
4349
|
-
notes:
|
|
4350
|
-
postId:
|
|
4876
|
+
confirmWrite: boolBody2(req, "confirmWrite"),
|
|
4877
|
+
notes: stringBody2(req, "notes"),
|
|
4878
|
+
postId: stringBody2(req, "postId") || ""
|
|
4351
4879
|
}));
|
|
4352
4880
|
});
|
|
4353
4881
|
router.post("/target/clear", (req, res) => {
|
|
4354
|
-
res.json(clearContentTarget(projectFrom2(bodyProjectShape(req)),
|
|
4882
|
+
res.json(clearContentTarget(projectFrom2(bodyProjectShape(req)), boolBody2(req, "confirmWrite")));
|
|
4355
4883
|
});
|
|
4356
4884
|
router.get("/batches/:batchId", (req, res) => {
|
|
4357
4885
|
res.json(getContentBatch(projectFrom2(req), req.params.batchId));
|
|
@@ -4365,52 +4893,55 @@ function contentBatchRouter(projectFrom2) {
|
|
|
4365
4893
|
});
|
|
4366
4894
|
router.post("/posts", (req, res) => {
|
|
4367
4895
|
res.json(createContentPost(projectFrom2(bodyProjectShape(req)), {
|
|
4368
|
-
batchId:
|
|
4369
|
-
body:
|
|
4370
|
-
campaign:
|
|
4371
|
-
channel:
|
|
4372
|
-
confirmWrite:
|
|
4373
|
-
cta:
|
|
4374
|
-
notes:
|
|
4375
|
-
phase:
|
|
4376
|
-
postId:
|
|
4377
|
-
sourcePath:
|
|
4378
|
-
title:
|
|
4896
|
+
batchId: stringBody2(req, "batchId") || "",
|
|
4897
|
+
body: stringBody2(req, "body"),
|
|
4898
|
+
campaign: stringBody2(req, "campaign"),
|
|
4899
|
+
channel: stringBody2(req, "channel") || "",
|
|
4900
|
+
confirmWrite: boolBody2(req, "confirmWrite"),
|
|
4901
|
+
cta: stringBody2(req, "cta"),
|
|
4902
|
+
notes: stringBody2(req, "notes"),
|
|
4903
|
+
phase: stringBody2(req, "phase"),
|
|
4904
|
+
postId: stringBody2(req, "postId") || stringBody2(req, "id") || "",
|
|
4905
|
+
sourcePath: stringBody2(req, "sourcePath"),
|
|
4906
|
+
title: stringBody2(req, "title") || ""
|
|
4379
4907
|
}));
|
|
4380
4908
|
});
|
|
4381
4909
|
router.post("/posts/:postId", (req, res) => {
|
|
4382
4910
|
res.json(updateContentPost(projectFrom2(bodyProjectShape(req)), {
|
|
4383
|
-
batchId:
|
|
4384
|
-
body:
|
|
4385
|
-
campaign:
|
|
4386
|
-
channel:
|
|
4387
|
-
confirmWrite:
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4911
|
+
batchId: stringBody2(req, "batchId"),
|
|
4912
|
+
body: stringBody2(req, "body"),
|
|
4913
|
+
campaign: stringBody2(req, "campaign"),
|
|
4914
|
+
channel: stringBody2(req, "channel"),
|
|
4915
|
+
confirmWrite: boolBody2(req, "confirmWrite"),
|
|
4916
|
+
claimToken: claimTokenFromRequest2(req),
|
|
4917
|
+
cta: stringBody2(req, "cta"),
|
|
4918
|
+
notes: stringBody2(req, "notes"),
|
|
4919
|
+
phase: stringBody2(req, "phase"),
|
|
4920
|
+
postedAt: stringBody2(req, "postedAt"),
|
|
4392
4921
|
postId: req.params.postId,
|
|
4393
|
-
scheduledAt:
|
|
4394
|
-
sourcePath:
|
|
4395
|
-
title:
|
|
4396
|
-
url:
|
|
4922
|
+
scheduledAt: stringBody2(req, "scheduledAt"),
|
|
4923
|
+
sourcePath: stringBody2(req, "sourcePath"),
|
|
4924
|
+
title: stringBody2(req, "title"),
|
|
4925
|
+
url: stringBody2(req, "url")
|
|
4397
4926
|
}));
|
|
4398
4927
|
});
|
|
4399
4928
|
router.post("/posts/:postId/assets", (req, res) => {
|
|
4400
4929
|
res.json(attachContentPostAsset(projectFrom2(bodyProjectShape(req)), {
|
|
4401
|
-
assetId:
|
|
4402
|
-
confirmWrite:
|
|
4403
|
-
|
|
4930
|
+
assetId: stringBody2(req, "assetId") || "",
|
|
4931
|
+
confirmWrite: boolBody2(req, "confirmWrite"),
|
|
4932
|
+
claimToken: claimTokenFromRequest2(req),
|
|
4933
|
+
notes: stringBody2(req, "notes"),
|
|
4404
4934
|
postId: req.params.postId,
|
|
4405
|
-
role:
|
|
4935
|
+
role: stringBody2(req, "role")
|
|
4406
4936
|
}));
|
|
4407
4937
|
});
|
|
4408
4938
|
router.post("/posts/:postId/assets/detach", (req, res) => {
|
|
4409
4939
|
res.json(detachContentPostAsset(projectFrom2(bodyProjectShape(req)), {
|
|
4410
|
-
assetId:
|
|
4411
|
-
confirmWrite:
|
|
4940
|
+
assetId: stringBody2(req, "assetId") || "",
|
|
4941
|
+
confirmWrite: boolBody2(req, "confirmWrite"),
|
|
4942
|
+
claimToken: claimTokenFromRequest2(req),
|
|
4412
4943
|
postId: req.params.postId,
|
|
4413
|
-
role:
|
|
4944
|
+
role: stringBody2(req, "role")
|
|
4414
4945
|
}));
|
|
4415
4946
|
});
|
|
4416
4947
|
return router;
|
|
@@ -4526,12 +5057,15 @@ function listImageGenerationJobs(project = defaultProject, fields = {}) {
|
|
|
4526
5057
|
}
|
|
4527
5058
|
|
|
4528
5059
|
// src/server/assetLineageDemo.ts
|
|
4529
|
-
import { createHash as
|
|
4530
|
-
import { mkdirSync as mkdirSync5, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
4531
|
-
import {
|
|
5060
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
5061
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync5, mkdirSync as mkdirSync5, mkdtempSync, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
5062
|
+
import { tmpdir } from "node:os";
|
|
5063
|
+
import { dirname as dirname3, join as join8, posix } from "node:path";
|
|
5064
|
+
import { gunzipSync } from "node:zlib";
|
|
4532
5065
|
var demoWorkspaceTitle = "Demo: Content iteration tree";
|
|
4533
5066
|
var demoWorkspaceNotes = "Repeatable sample lineage for demos and onboarding. Archive it when reviewing real work.";
|
|
4534
5067
|
var demoBasePath = ["lineage-demo", "2026-06-lineage-demo"];
|
|
5068
|
+
var swissifierManifestPath = join8(repoRoot, "fixtures", defaultProject, "lineage", "swissifier-rich-demo.json");
|
|
4535
5069
|
var demoAssets = [
|
|
4536
5070
|
{ key: "root", channel: "linkedin", file: "demo-root.svg", label: "Initial Demo Concept", fill: "#f6fbfb", stroke: "#0b7f88" },
|
|
4537
5071
|
{ key: "hookA", channel: "tiktok", file: "demo-hook-a-v01.svg", label: "Hook A v01", fill: "#fff8e6", stroke: "#9a6a00" },
|
|
@@ -4573,25 +5107,288 @@ function demoProjectDir(project) {
|
|
|
4573
5107
|
function demoRelativePath(project, asset) {
|
|
4574
5108
|
return join8(...demoBasePath, project, asset.channel, asset.file);
|
|
4575
5109
|
}
|
|
4576
|
-
function
|
|
5110
|
+
function demoFilePath(project, asset) {
|
|
5111
|
+
return join8(demoProjectDir(project), asset.channel, asset.file);
|
|
5112
|
+
}
|
|
5113
|
+
function swissifierManifest() {
|
|
5114
|
+
return JSON.parse(readFileSync4(swissifierManifestPath, "utf8"));
|
|
5115
|
+
}
|
|
5116
|
+
function swissifierRelativePath(manifest, asset) {
|
|
5117
|
+
return join8(manifest.media.target_dir, asset.file);
|
|
5118
|
+
}
|
|
5119
|
+
function swissifierFilePath(manifest, asset) {
|
|
5120
|
+
return join8(repoRoot, ".asset-scratch", swissifierRelativePath(manifest, asset));
|
|
5121
|
+
}
|
|
5122
|
+
function swissifierSourcePath(manifest, asset, sourceDir) {
|
|
5123
|
+
const direct = join8(sourceDir, asset.file);
|
|
5124
|
+
if (existsSync5(direct)) return direct;
|
|
5125
|
+
const nested = join8(sourceDir, manifest.media.target_dir, asset.file);
|
|
5126
|
+
return existsSync5(nested) ? nested : null;
|
|
5127
|
+
}
|
|
5128
|
+
function swissifierMediaState(manifest) {
|
|
5129
|
+
const missing = [];
|
|
5130
|
+
const invalid = [];
|
|
5131
|
+
for (const asset of manifest.assets) {
|
|
5132
|
+
const relativePath = swissifierRelativePath(manifest, asset);
|
|
5133
|
+
const path = swissifierFilePath(manifest, asset);
|
|
5134
|
+
if (!existsSync5(path)) {
|
|
5135
|
+
missing.push(relativePath);
|
|
5136
|
+
continue;
|
|
5137
|
+
}
|
|
5138
|
+
if (fileSha256(path) !== asset.checksum_sha256) invalid.push(relativePath);
|
|
5139
|
+
}
|
|
5140
|
+
return { invalid, missing };
|
|
5141
|
+
}
|
|
5142
|
+
function sha256Hex(input) {
|
|
5143
|
+
return createHash3("sha256").update(input).digest("hex");
|
|
5144
|
+
}
|
|
5145
|
+
function safeTarEntryName(rawName) {
|
|
5146
|
+
const stripped = rawName.replace(/\0.*$/, "").replace(/^\.\//, "");
|
|
5147
|
+
if (!stripped || stripped === ".") return "";
|
|
5148
|
+
if (stripped.includes("\\") || stripped.startsWith("/") || /^[A-Za-z]:/.test(stripped)) {
|
|
5149
|
+
throw new Error(`Unsafe Swissifier media archive path: ${rawName}`);
|
|
5150
|
+
}
|
|
5151
|
+
const normalized = posix.normalize(stripped);
|
|
5152
|
+
if (!normalized || normalized === "." || normalized.startsWith("../") || normalized === "..") {
|
|
5153
|
+
throw new Error(`Unsafe Swissifier media archive path: ${rawName}`);
|
|
5154
|
+
}
|
|
5155
|
+
return normalized;
|
|
5156
|
+
}
|
|
5157
|
+
function tarOctal(header, start, length) {
|
|
5158
|
+
const raw = header.toString("utf8", start, start + length).replace(/\0.*$/, "").trim();
|
|
5159
|
+
if (!raw) return 0;
|
|
5160
|
+
if (!/^[0-7]+$/.test(raw)) throw new Error(`Invalid Swissifier media archive size: ${raw}`);
|
|
5161
|
+
return Number.parseInt(raw, 8);
|
|
5162
|
+
}
|
|
5163
|
+
function tarName(header) {
|
|
5164
|
+
const name = header.toString("utf8", 0, 100).replace(/\0.*$/, "");
|
|
5165
|
+
const prefix = header.toString("utf8", 345, 500).replace(/\0.*$/, "");
|
|
5166
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
5167
|
+
}
|
|
5168
|
+
function isAppleDoubleEntry(name) {
|
|
5169
|
+
return name === "._." || name.startsWith("._") || name.includes("/._");
|
|
5170
|
+
}
|
|
5171
|
+
function extractSwissifierMediaArchive(archive, manifest, destination) {
|
|
5172
|
+
const expected = new Map(manifest.assets.map((asset) => [asset.file, asset]));
|
|
5173
|
+
const extracted = /* @__PURE__ */ new Set();
|
|
5174
|
+
const body = gunzipSync(archive);
|
|
5175
|
+
let offset = 0;
|
|
5176
|
+
while (offset + 512 <= body.length) {
|
|
5177
|
+
const header = body.subarray(offset, offset + 512);
|
|
5178
|
+
offset += 512;
|
|
5179
|
+
if (header.every((byte) => byte === 0)) break;
|
|
5180
|
+
const typeflag = header.toString("utf8", 156, 157);
|
|
5181
|
+
const rawName = tarName(header);
|
|
5182
|
+
const name = safeTarEntryName(rawName);
|
|
5183
|
+
const size = tarOctal(header, 124, 12);
|
|
5184
|
+
const nextOffset = offset + Math.ceil(size / 512) * 512;
|
|
5185
|
+
if (nextOffset > body.length) throw new Error(`Truncated Swissifier media archive entry: ${rawName}`);
|
|
5186
|
+
if (!name || typeflag === "5") {
|
|
5187
|
+
offset = nextOffset;
|
|
5188
|
+
continue;
|
|
5189
|
+
}
|
|
5190
|
+
if (typeflag === "x" || typeflag === "g") {
|
|
5191
|
+
offset = nextOffset;
|
|
5192
|
+
continue;
|
|
5193
|
+
}
|
|
5194
|
+
if (isAppleDoubleEntry(name)) {
|
|
5195
|
+
offset = nextOffset;
|
|
5196
|
+
continue;
|
|
5197
|
+
}
|
|
5198
|
+
if (typeflag && typeflag !== "0") throw new Error(`Unsupported Swissifier media archive entry type for ${name}`);
|
|
5199
|
+
const directName = name.startsWith(`${manifest.media.target_dir}/`) ? name.slice(manifest.media.target_dir.length + 1) : name;
|
|
5200
|
+
const asset = expected.get(directName);
|
|
5201
|
+
if (!asset) throw new Error(`Unexpected Swissifier media archive entry: ${name}`);
|
|
5202
|
+
if (size !== asset.size_bytes) throw new Error(`Unexpected Swissifier media archive size for ${name}`);
|
|
5203
|
+
const file = body.subarray(offset, offset + size);
|
|
5204
|
+
const actualSha = sha256Hex(file);
|
|
5205
|
+
if (actualSha !== asset.checksum_sha256) throw new Error(`Checksum mismatch for Swissifier media archive entry: ${name}`);
|
|
5206
|
+
const target = join8(destination, directName);
|
|
5207
|
+
mkdirSync5(dirname3(target), { recursive: true });
|
|
5208
|
+
writeFileSync3(target, file);
|
|
5209
|
+
extracted.add(directName);
|
|
5210
|
+
offset = nextOffset;
|
|
5211
|
+
}
|
|
5212
|
+
const missing = manifest.assets.map((asset) => asset.file).filter((file) => !extracted.has(file));
|
|
5213
|
+
if (missing.length) throw new Error(`Swissifier media archive missing ${missing.length} expected file${missing.length === 1 ? "" : "s"}`);
|
|
5214
|
+
return { extracted: extracted.size };
|
|
5215
|
+
}
|
|
5216
|
+
async function downloadBuffer(url, maxBytes) {
|
|
5217
|
+
const response = await fetch(url);
|
|
5218
|
+
if (!response.ok) throw new Error(`Swissifier media download failed with HTTP ${response.status}`);
|
|
5219
|
+
const contentLength = Number(response.headers.get("content-length") || 0);
|
|
5220
|
+
if (contentLength > maxBytes) throw new Error(`Swissifier media download is too large: ${contentLength} bytes`);
|
|
5221
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
5222
|
+
if (buffer.length > maxBytes) throw new Error(`Swissifier media download is too large: ${buffer.length} bytes`);
|
|
5223
|
+
return buffer;
|
|
5224
|
+
}
|
|
5225
|
+
function missingDemoMedia(project) {
|
|
5226
|
+
return demoAssets.filter((asset) => !existsSync5(demoFilePath(project, asset))).map((asset) => demoRelativePath(project, asset));
|
|
5227
|
+
}
|
|
5228
|
+
function demoSeedMediaStatus(project = defaultProject) {
|
|
5229
|
+
const missing = missingDemoMedia(project);
|
|
5230
|
+
const present = demoAssets.length - missing.length;
|
|
4577
5231
|
return {
|
|
4578
5232
|
ok: true,
|
|
4579
5233
|
media_root: join8(repoRoot, ".asset-scratch"),
|
|
4580
|
-
present
|
|
5234
|
+
present,
|
|
4581
5235
|
total: demoAssets.length,
|
|
4582
|
-
missing
|
|
4583
|
-
fixture_present:
|
|
5236
|
+
missing,
|
|
5237
|
+
fixture_present: present,
|
|
4584
5238
|
fixture_total: demoAssets.length,
|
|
4585
|
-
fixture_missing:
|
|
5239
|
+
fixture_missing: missing
|
|
4586
5240
|
};
|
|
4587
5241
|
}
|
|
4588
|
-
function
|
|
5242
|
+
function swissifierRichDemoMediaStatus(project = defaultProject) {
|
|
5243
|
+
const manifest = swissifierManifest();
|
|
5244
|
+
const { invalid, missing } = swissifierMediaState(manifest);
|
|
5245
|
+
const present = manifest.assets.length - missing.length - invalid.length;
|
|
4589
5246
|
return {
|
|
4590
5247
|
ok: true,
|
|
5248
|
+
demo_id: manifest.id,
|
|
5249
|
+
project,
|
|
5250
|
+
media_root: join8(repoRoot, ".asset-scratch"),
|
|
5251
|
+
media_target: manifest.media.target_dir,
|
|
5252
|
+
download_available: Boolean(manifest.media.download),
|
|
5253
|
+
download_file: manifest.media.download?.file,
|
|
5254
|
+
download_sha256: manifest.media.download?.sha256,
|
|
5255
|
+
download_url: manifest.media.download?.url,
|
|
5256
|
+
source_env: manifest.media.source_env,
|
|
5257
|
+
source_hint: manifest.media.source_hint,
|
|
5258
|
+
present,
|
|
5259
|
+
total: manifest.assets.length,
|
|
5260
|
+
missing,
|
|
5261
|
+
invalid,
|
|
5262
|
+
fixture_present: present,
|
|
5263
|
+
fixture_total: manifest.assets.length,
|
|
5264
|
+
fixture_missing: [...missing, ...invalid]
|
|
5265
|
+
};
|
|
5266
|
+
}
|
|
5267
|
+
async function downloadSwissifierRichDemoMedia(project = defaultProject, fields = { confirmWrite: false }) {
|
|
5268
|
+
const manifest = swissifierManifest();
|
|
5269
|
+
const download = manifest.media.download;
|
|
5270
|
+
if (!download) {
|
|
5271
|
+
return {
|
|
5272
|
+
ok: true,
|
|
5273
|
+
demo_id: manifest.id,
|
|
5274
|
+
project,
|
|
5275
|
+
download_available: false,
|
|
5276
|
+
restored: 0,
|
|
5277
|
+
total: manifest.assets.length
|
|
5278
|
+
};
|
|
5279
|
+
}
|
|
5280
|
+
const sourceUrl = fields.sourceUrl || download.url;
|
|
5281
|
+
const expectedSha256 = fields.expectedSha256 || download.sha256;
|
|
5282
|
+
const maxBytes = Math.max(download.size_bytes + 1024 * 1024, download.size_bytes * 1.1);
|
|
5283
|
+
const archive = await downloadBuffer(sourceUrl, maxBytes);
|
|
5284
|
+
const archiveSha256 = sha256Hex(archive);
|
|
5285
|
+
if (archiveSha256 !== expectedSha256) throw new Error(`Swissifier media download checksum mismatch: expected ${expectedSha256}, got ${archiveSha256}`);
|
|
5286
|
+
if (!fields.confirmWrite) {
|
|
5287
|
+
return {
|
|
5288
|
+
ok: true,
|
|
5289
|
+
demo_id: manifest.id,
|
|
5290
|
+
project,
|
|
5291
|
+
dryRun: true,
|
|
5292
|
+
download_available: true,
|
|
5293
|
+
download_file: download.file,
|
|
5294
|
+
download_url: sourceUrl,
|
|
5295
|
+
archive_sha256: archiveSha256,
|
|
5296
|
+
restored: 0,
|
|
5297
|
+
total: manifest.assets.length,
|
|
5298
|
+
would_restore: manifest.assets.length
|
|
5299
|
+
};
|
|
5300
|
+
}
|
|
5301
|
+
const sourceDir = mkdtempSync(join8(tmpdir(), "lineage-swissifier-media-"));
|
|
5302
|
+
try {
|
|
5303
|
+
const extracted = extractSwissifierMediaArchive(archive, manifest, sourceDir);
|
|
5304
|
+
const restored = restoreSwissifierRichDemoMedia(project, { confirmWrite: true, sourceDir });
|
|
5305
|
+
return {
|
|
5306
|
+
...restored,
|
|
5307
|
+
download_available: true,
|
|
5308
|
+
download_file: download.file,
|
|
5309
|
+
download_url: sourceUrl,
|
|
5310
|
+
archive_sha256: archiveSha256,
|
|
5311
|
+
extracted: extracted.extracted,
|
|
5312
|
+
media_status: swissifierRichDemoMediaStatus(project)
|
|
5313
|
+
};
|
|
5314
|
+
} finally {
|
|
5315
|
+
rmSync(sourceDir, { force: true, recursive: true });
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
function restoreDemoSeedMedia(project = defaultProject, fields = { confirmWrite: false }) {
|
|
5319
|
+
const missing = new Set(missingDemoMedia(project));
|
|
5320
|
+
if (fields.confirmWrite) {
|
|
5321
|
+
for (const asset of demoAssets) {
|
|
5322
|
+
if (missing.has(demoRelativePath(project, asset))) writeDemoFile(project, asset);
|
|
5323
|
+
}
|
|
5324
|
+
}
|
|
5325
|
+
return {
|
|
5326
|
+
ok: true,
|
|
5327
|
+
dryRun: !fields.confirmWrite,
|
|
5328
|
+
media_root: join8(repoRoot, ".asset-scratch"),
|
|
5329
|
+
restored: fields.confirmWrite ? missing.size : 0,
|
|
5330
|
+
total: demoAssets.length,
|
|
5331
|
+
would_restore: fields.confirmWrite ? 0 : missing.size,
|
|
5332
|
+
missing: Array.from(missing)
|
|
5333
|
+
};
|
|
5334
|
+
}
|
|
5335
|
+
function restoreSwissifierRichDemoMedia(project = defaultProject, fields = { confirmWrite: false }) {
|
|
5336
|
+
const manifest = swissifierManifest();
|
|
5337
|
+
const before = swissifierMediaState(manifest);
|
|
5338
|
+
const sourceDir = fields.sourceDir || process.env[manifest.media.source_env];
|
|
5339
|
+
if (!sourceDir) {
|
|
5340
|
+
return {
|
|
5341
|
+
ok: true,
|
|
5342
|
+
demo_id: manifest.id,
|
|
5343
|
+
project,
|
|
5344
|
+
dryRun: !fields.confirmWrite,
|
|
5345
|
+
media_root: join8(repoRoot, ".asset-scratch"),
|
|
5346
|
+
restored: 0,
|
|
5347
|
+
total: manifest.assets.length,
|
|
5348
|
+
would_restore: 0,
|
|
5349
|
+
source_required: true,
|
|
5350
|
+
source_env: manifest.media.source_env,
|
|
5351
|
+
source_hint: manifest.media.source_hint,
|
|
5352
|
+
missing: before.missing,
|
|
5353
|
+
invalid: before.invalid,
|
|
5354
|
+
unavailable: [...before.missing, ...before.invalid]
|
|
5355
|
+
};
|
|
5356
|
+
}
|
|
5357
|
+
const unavailable = [];
|
|
5358
|
+
const copyable = [];
|
|
5359
|
+
const wanted = /* @__PURE__ */ new Set([...before.missing, ...before.invalid]);
|
|
5360
|
+
for (const asset of manifest.assets) {
|
|
5361
|
+
const relativePath = swissifierRelativePath(manifest, asset);
|
|
5362
|
+
if (!wanted.has(relativePath)) continue;
|
|
5363
|
+
const source = swissifierSourcePath(manifest, asset, sourceDir);
|
|
5364
|
+
if (!source || fileSha256(source) !== asset.checksum_sha256) {
|
|
5365
|
+
unavailable.push(relativePath);
|
|
5366
|
+
continue;
|
|
5367
|
+
}
|
|
5368
|
+
copyable.push({ asset, source });
|
|
5369
|
+
}
|
|
5370
|
+
if (fields.confirmWrite) {
|
|
5371
|
+
for (const item of copyable) {
|
|
5372
|
+
const target = swissifierFilePath(manifest, item.asset);
|
|
5373
|
+
mkdirSync5(dirname3(target), { recursive: true });
|
|
5374
|
+
copyFileSync2(item.source, target);
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5377
|
+
return {
|
|
5378
|
+
ok: true,
|
|
5379
|
+
demo_id: manifest.id,
|
|
5380
|
+
project,
|
|
4591
5381
|
dryRun: !fields.confirmWrite,
|
|
4592
5382
|
media_root: join8(repoRoot, ".asset-scratch"),
|
|
4593
|
-
restored: 0,
|
|
4594
|
-
total:
|
|
5383
|
+
restored: fields.confirmWrite ? copyable.length : 0,
|
|
5384
|
+
total: manifest.assets.length,
|
|
5385
|
+
would_restore: fields.confirmWrite ? 0 : copyable.length,
|
|
5386
|
+
source_required: false,
|
|
5387
|
+
source_env: manifest.media.source_env,
|
|
5388
|
+
source_hint: manifest.media.source_hint,
|
|
5389
|
+
missing: before.missing,
|
|
5390
|
+
invalid: before.invalid,
|
|
5391
|
+
unavailable
|
|
4595
5392
|
};
|
|
4596
5393
|
}
|
|
4597
5394
|
function svg(label, fill, stroke) {
|
|
@@ -4604,18 +5401,19 @@ function svg(label, fill, stroke) {
|
|
|
4604
5401
|
`;
|
|
4605
5402
|
}
|
|
4606
5403
|
function assetIdFor(asset) {
|
|
4607
|
-
return `local-${
|
|
5404
|
+
return `local-${createHash3("sha256").update(svg(asset.label, asset.fill, asset.stroke)).digest("hex").slice(0, 12)}`;
|
|
4608
5405
|
}
|
|
4609
5406
|
function demoAssetIds() {
|
|
4610
5407
|
return Object.fromEntries(demoAssets.map((asset) => [asset.key, assetIdFor(asset)]));
|
|
4611
5408
|
}
|
|
5409
|
+
function writeDemoFile(project, asset) {
|
|
5410
|
+
const path = demoFilePath(project, asset);
|
|
5411
|
+
mkdirSync5(dirname3(path), { recursive: true });
|
|
5412
|
+
writeFileSync3(path, svg(asset.label, asset.fill, asset.stroke));
|
|
5413
|
+
}
|
|
4612
5414
|
function writeDemoFiles(project) {
|
|
4613
5415
|
const ids = demoAssetIds();
|
|
4614
|
-
for (const asset of demoAssets)
|
|
4615
|
-
const path = join8(demoProjectDir(project), asset.channel, asset.file);
|
|
4616
|
-
mkdirSync5(dirname3(path), { recursive: true });
|
|
4617
|
-
writeFileSync3(path, svg(asset.label, asset.fill, asset.stroke));
|
|
4618
|
-
}
|
|
5416
|
+
for (const asset of demoAssets) writeDemoFile(project, asset);
|
|
4619
5417
|
return ids;
|
|
4620
5418
|
}
|
|
4621
5419
|
function upsertDemoAssets(project, ids) {
|
|
@@ -4649,7 +5447,7 @@ function upsertDemoAssets(project, ids) {
|
|
|
4649
5447
|
ids[asset.key],
|
|
4650
5448
|
project,
|
|
4651
5449
|
demoRelativePath(project, asset),
|
|
4652
|
-
|
|
5450
|
+
createHash3("sha256").update(body).digest("hex"),
|
|
4653
5451
|
asset.label,
|
|
4654
5452
|
asset.channel,
|
|
4655
5453
|
Buffer.byteLength(body),
|
|
@@ -4664,6 +5462,55 @@ function upsertDemoAssets(project, ids) {
|
|
|
4664
5462
|
}
|
|
4665
5463
|
return { catalog: 0, local: demoAssets.length, total: demoAssets.length };
|
|
4666
5464
|
}
|
|
5465
|
+
function upsertSwissifierAssets(project, manifest) {
|
|
5466
|
+
const database = lineageDb();
|
|
5467
|
+
const timestamp = nowIso();
|
|
5468
|
+
try {
|
|
5469
|
+
database.prepare(`
|
|
5470
|
+
insert into projects (id, product, catalog_path, created_at, updated_at)
|
|
5471
|
+
values (?, ?, ?, ?, ?)
|
|
5472
|
+
on conflict(id) do update set product = excluded.product, updated_at = excluded.updated_at
|
|
5473
|
+
`).run(project, project, join8(repoRoot, project, "assets", "catalog.json"), timestamp, timestamp);
|
|
5474
|
+
const assetStatement = database.prepare(`
|
|
5475
|
+
insert into assets (
|
|
5476
|
+
id, project_id, source, local_path, s3_key, checksum_sha256, media_type, title, status,
|
|
5477
|
+
channel, campaign, audience, size_bytes, content_type, created_at, updated_at, last_seen_at
|
|
5478
|
+
) values (?, ?, 'local', ?, null, ?, 'image', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
5479
|
+
on conflict(id) do update set
|
|
5480
|
+
project_id = excluded.project_id, source = excluded.source, local_path = excluded.local_path, checksum_sha256 = excluded.checksum_sha256,
|
|
5481
|
+
media_type = excluded.media_type, title = excluded.title, status = excluded.status, channel = excluded.channel,
|
|
5482
|
+
campaign = excluded.campaign, audience = excluded.audience, size_bytes = excluded.size_bytes,
|
|
5483
|
+
content_type = excluded.content_type, updated_at = excluded.updated_at, last_seen_at = excluded.last_seen_at
|
|
5484
|
+
`);
|
|
5485
|
+
const reviewStatement = database.prepare(`
|
|
5486
|
+
insert into asset_reviews (asset_id, review_state, updated_at)
|
|
5487
|
+
values (?, 'unreviewed', ?)
|
|
5488
|
+
on conflict(asset_id) do nothing
|
|
5489
|
+
`);
|
|
5490
|
+
for (const asset of manifest.assets) {
|
|
5491
|
+
assetStatement.run(
|
|
5492
|
+
asset.asset_id,
|
|
5493
|
+
project,
|
|
5494
|
+
swissifierRelativePath(manifest, asset),
|
|
5495
|
+
asset.checksum_sha256,
|
|
5496
|
+
asset.title,
|
|
5497
|
+
asset.status,
|
|
5498
|
+
asset.channel,
|
|
5499
|
+
manifest.campaign,
|
|
5500
|
+
manifest.audience,
|
|
5501
|
+
asset.size_bytes,
|
|
5502
|
+
asset.content_type,
|
|
5503
|
+
timestamp,
|
|
5504
|
+
timestamp,
|
|
5505
|
+
timestamp
|
|
5506
|
+
);
|
|
5507
|
+
reviewStatement.run(asset.asset_id, timestamp);
|
|
5508
|
+
}
|
|
5509
|
+
} finally {
|
|
5510
|
+
database.close();
|
|
5511
|
+
}
|
|
5512
|
+
return { catalog: 0, local: manifest.assets.length, total: manifest.assets.length };
|
|
5513
|
+
}
|
|
4667
5514
|
function seedDemoLineageWorkspace(project, fields) {
|
|
4668
5515
|
const ids = fields.confirmWrite ? writeDemoFiles(project) : demoAssetIds();
|
|
4669
5516
|
const rootAssetId = ids.root;
|
|
@@ -4709,6 +5556,53 @@ function seedDemoLineageWorkspace(project, fields) {
|
|
|
4709
5556
|
workspace
|
|
4710
5557
|
};
|
|
4711
5558
|
}
|
|
5559
|
+
function seedSwissifierRichDemoWorkspace(project, fields) {
|
|
5560
|
+
const manifest = swissifierManifest();
|
|
5561
|
+
if (!fields.confirmWrite) {
|
|
5562
|
+
return {
|
|
5563
|
+
ok: true,
|
|
5564
|
+
dryRun: true,
|
|
5565
|
+
demo_id: manifest.id,
|
|
5566
|
+
root_asset_id: manifest.root_asset_id,
|
|
5567
|
+
workspace_id: lineageWorkspaceId(project, manifest.root_asset_id),
|
|
5568
|
+
media_status: swissifierRichDemoMediaStatus(project)
|
|
5569
|
+
};
|
|
5570
|
+
}
|
|
5571
|
+
const summary = upsertSwissifierAssets(project, manifest);
|
|
5572
|
+
for (const edge of manifest.edges) {
|
|
5573
|
+
linkLineageAssets(project, { parentAssetId: edge.parent, childAssetId: edge.child, confirmWrite: true });
|
|
5574
|
+
}
|
|
5575
|
+
updateSelectedAsset(project, {
|
|
5576
|
+
assetIds: manifest.selected_asset_ids,
|
|
5577
|
+
confirmWrite: true,
|
|
5578
|
+
maxSelections: manifest.selected_asset_ids.length,
|
|
5579
|
+
notes: "Swissifier rich demo bases selected for the next variation.",
|
|
5580
|
+
rootAssetId: manifest.root_asset_id
|
|
5581
|
+
});
|
|
5582
|
+
updateLineageLayout(project, {
|
|
5583
|
+
confirmWrite: true,
|
|
5584
|
+
rootAssetId: manifest.root_asset_id,
|
|
5585
|
+
positions: manifest.assets.map((asset) => ({ assetId: asset.asset_id, ...asset.position }))
|
|
5586
|
+
});
|
|
5587
|
+
const workspace = createLineageWorkspace(project, {
|
|
5588
|
+
activate: fields.activate !== false,
|
|
5589
|
+
confirmWrite: true,
|
|
5590
|
+
createdBy: "system",
|
|
5591
|
+
notes: manifest.notes,
|
|
5592
|
+
rootAssetId: manifest.root_asset_id,
|
|
5593
|
+
title: manifest.title
|
|
5594
|
+
}).workspace;
|
|
5595
|
+
return {
|
|
5596
|
+
ok: true,
|
|
5597
|
+
message: `Seeded ${manifest.title}`,
|
|
5598
|
+
demo_id: manifest.id,
|
|
5599
|
+
media_status: swissifierRichDemoMediaStatus(project),
|
|
5600
|
+
root_asset_id: manifest.root_asset_id,
|
|
5601
|
+
selected_asset_ids: manifest.selected_asset_ids,
|
|
5602
|
+
summary,
|
|
5603
|
+
workspace
|
|
5604
|
+
};
|
|
5605
|
+
}
|
|
4712
5606
|
function archiveDemoLineageWorkspace(project, confirmWrite) {
|
|
4713
5607
|
const ids = demoAssetIds();
|
|
4714
5608
|
const rootAssetId = ids.root;
|
|
@@ -4756,11 +5650,37 @@ function registerLineageWorkspaceRoutes(app2, projectFrom2, asyncRoute2) {
|
|
|
4756
5650
|
app2.post("/api/lineage-workspaces/demo/archive", asyncRoute2((req, res) => {
|
|
4757
5651
|
res.json(archiveDemoLineageWorkspace(projectFrom2(req), req.body.confirmWrite === true));
|
|
4758
5652
|
}));
|
|
4759
|
-
app2.
|
|
4760
|
-
res.json(
|
|
5653
|
+
app2.post("/api/lineage-workspaces/demo/swissifier/seed", asyncRoute2((req, res) => {
|
|
5654
|
+
res.json(seedSwissifierRichDemoWorkspace(projectFrom2(req), {
|
|
5655
|
+
activate: req.body.activate !== false,
|
|
5656
|
+
confirmWrite: req.body.confirmWrite === true
|
|
5657
|
+
}));
|
|
5658
|
+
}));
|
|
5659
|
+
app2.get("/api/lineage-workspaces/demo/media", asyncRoute2((req, res) => {
|
|
5660
|
+
res.json({ ok: true, status: demoSeedMediaStatus(projectFrom2(req)) });
|
|
5661
|
+
}));
|
|
5662
|
+
app2.get("/api/lineage-workspaces/demo/swissifier/media", asyncRoute2((req, res) => {
|
|
5663
|
+
res.json({ ok: true, status: swissifierRichDemoMediaStatus(projectFrom2(req)) });
|
|
4761
5664
|
}));
|
|
4762
5665
|
app2.post("/api/lineage-workspaces/demo/media/restore", asyncRoute2((req, res) => {
|
|
4763
|
-
res.json({ ok: true, result: restoreDemoSeedMedia({ confirmWrite: req.body.confirmWrite === true }) });
|
|
5666
|
+
res.json({ ok: true, result: restoreDemoSeedMedia(projectFrom2(req), { confirmWrite: req.body.confirmWrite === true }) });
|
|
5667
|
+
}));
|
|
5668
|
+
app2.post("/api/lineage-workspaces/demo/swissifier/media/restore", asyncRoute2((req, res) => {
|
|
5669
|
+
res.json({
|
|
5670
|
+
ok: true,
|
|
5671
|
+
result: restoreSwissifierRichDemoMedia(projectFrom2(req), {
|
|
5672
|
+
confirmWrite: req.body.confirmWrite === true,
|
|
5673
|
+
sourceDir: typeof req.body.sourceDir === "string" ? req.body.sourceDir : void 0
|
|
5674
|
+
})
|
|
5675
|
+
});
|
|
5676
|
+
}));
|
|
5677
|
+
app2.post("/api/lineage-workspaces/demo/swissifier/media/download", asyncRoute2(async (req, res) => {
|
|
5678
|
+
res.json({
|
|
5679
|
+
ok: true,
|
|
5680
|
+
result: await downloadSwissifierRichDemoMedia(projectFrom2(req), {
|
|
5681
|
+
confirmWrite: req.body.confirmWrite === true
|
|
5682
|
+
})
|
|
5683
|
+
});
|
|
4764
5684
|
}));
|
|
4765
5685
|
app2.get("/api/lineage-workspaces/:workspaceId", asyncRoute2((req, res) => {
|
|
4766
5686
|
res.json({
|
|
@@ -4789,7 +5709,7 @@ function registerLineageWorkspaceRoutes(app2, projectFrom2, asyncRoute2) {
|
|
|
4789
5709
|
// src/server.ts
|
|
4790
5710
|
var app = express2();
|
|
4791
5711
|
var port = Number(process.env.PORT || 5173);
|
|
4792
|
-
var host = process.env.HOST || "
|
|
5712
|
+
var host = process.env.HOST || "lineage.localhost";
|
|
4793
5713
|
var isProduction = process.env.NODE_ENV === "production";
|
|
4794
5714
|
var maxUploadBytes = Number(process.env.LINEAGE_MAX_UPLOAD_MB || 200) * 1024 * 1024;
|
|
4795
5715
|
var upload = multer({ dest: ensureUploadDir(), limits: { fileSize: maxUploadBytes } });
|
|
@@ -4833,6 +5753,7 @@ app.post("/api/assets/lookup", asyncRoute((req, res) => {
|
|
|
4833
5753
|
res.json(lookupAssets(projectFrom(req), Array.isArray(req.body.assetIds) ? req.body.assetIds.map(String) : []));
|
|
4834
5754
|
}));
|
|
4835
5755
|
registerAdapterRoutes(app, projectFrom, asyncRoute);
|
|
5756
|
+
registerAgentClaimRoutes(app, projectFrom, asyncRoute);
|
|
4836
5757
|
app.use("/api/content", contentBatchRouter(projectFrom));
|
|
4837
5758
|
app.use("/api/selections", assetSelectionRouter(projectFrom));
|
|
4838
5759
|
app.get("/api/generation/jobs", asyncRoute((req, res) => {
|
|
@@ -4926,7 +5847,8 @@ app.post(
|
|
|
4926
5847
|
linkSelectedLineageChild(projectFrom(req), {
|
|
4927
5848
|
rootAssetId: typeof req.body.rootAssetId === "string" ? req.body.rootAssetId : void 0,
|
|
4928
5849
|
childAssetId: String(req.body.childAssetId || ""),
|
|
4929
|
-
confirmWrite: req.body.confirmWrite === true
|
|
5850
|
+
confirmWrite: req.body.confirmWrite === true,
|
|
5851
|
+
claimToken: claimTokenFromRequest(req)
|
|
4930
5852
|
})
|
|
4931
5853
|
);
|
|
4932
5854
|
})
|
|
@@ -5092,7 +6014,7 @@ app.post(
|
|
|
5092
6014
|
);
|
|
5093
6015
|
if (isProduction) {
|
|
5094
6016
|
const dist = join9(repoRoot, "dist", "web");
|
|
5095
|
-
if (
|
|
6017
|
+
if (existsSync6(dist)) {
|
|
5096
6018
|
app.use(express2.static(dist));
|
|
5097
6019
|
app.get("*", (_req, res) => res.sendFile(join9(dist, "index.html")));
|
|
5098
6020
|
}
|
|
@@ -5150,6 +6072,10 @@ app.use((error, _req, res, _next) => {
|
|
|
5150
6072
|
res.status(error.status).json({ error: error.message });
|
|
5151
6073
|
return;
|
|
5152
6074
|
}
|
|
6075
|
+
if (isAgentClaimError(error)) {
|
|
6076
|
+
res.status(error.status).json({ error: error.code, message: error.message, conflicts: error.conflicts });
|
|
6077
|
+
return;
|
|
6078
|
+
}
|
|
5153
6079
|
if (isLineageWorkspaceError(error)) {
|
|
5154
6080
|
res.status(error.status).json({ error: error.message });
|
|
5155
6081
|
return;
|