@saadnvd1/agent-os 0.3.761 → 0.3.765
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/app/api/artifacts/[id]/route.ts +13 -4
- package/app/api/artifacts/[id]/versions/route.ts +16 -0
- package/app/api/sessions/[id]/artifacts/route.ts +9 -1
- package/app/api/shares/route.test.ts +66 -1
- package/app/api/shares/route.ts +15 -0
- package/components/Artifacts/ArtifactActions.tsx +308 -0
- package/components/Artifacts/ArtifactFullscreen.tsx +58 -0
- package/components/Artifacts/ArtifactPreview.tsx +141 -18
- package/components/Artifacts/ArtifactRow.tsx +5 -0
- package/components/Artifacts/ArtifactsPanel.tsx +2 -0
- package/components/Artifacts/VersionSwitcher.tsx +216 -0
- package/components/Chat/Artifact.tsx +129 -89
- package/components/Share/DownloadMenu.tsx +63 -38
- package/components/Share/ShareDialog.tsx +47 -0
- package/components/Shares/SharedLinksPanel.tsx +7 -0
- package/data/session-artifacts/keys.ts +3 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/lib/artifacts/actions.ts +75 -0
- package/lib/artifacts/store.ts +105 -10
- package/lib/artifacts/tools.ts +33 -6
- package/lib/artifacts/versions.ts +73 -0
- package/lib/chat/events.ts +4 -0
- package/lib/chat/worker/host.ts +2 -0
- package/lib/connect/control-api.ts +15 -0
- package/lib/db/migrations/20261029T1200_artifact_versions.ts +28 -0
- package/lib/db/migrations/20261029T1300_shared_links_follow.ts +28 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-artifacts/collect.ts +28 -6
- package/lib/session-artifacts/derive.ts +3 -1
- package/lib/session-artifacts/types.ts +9 -0
- package/lib/share/connect.ts +10 -0
- package/lib/share/follow.ts +76 -0
- package/lib/share/service.ts +6 -0
- package/lib/share/store.ts +34 -2
- package/lib/share/view.ts +6 -0
- package/package.json +1 -1
- package/stores/shareUi.ts +6 -2
package/lib/artifacts/store.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { agentosHome } from "../agentos-home";
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
|
-
import { randomUUID } from "crypto";
|
|
9
|
+
import { createHash, randomUUID } from "crypto";
|
|
10
10
|
import { db } from "../db";
|
|
11
11
|
|
|
12
12
|
export interface Artifact {
|
|
@@ -15,8 +15,31 @@ export interface Artifact {
|
|
|
15
15
|
title: string;
|
|
16
16
|
path: string;
|
|
17
17
|
created_at: string;
|
|
18
|
+
// The page this is a version of; every version of a page shares it.
|
|
19
|
+
page_id: string;
|
|
20
|
+
version: number;
|
|
21
|
+
// The agent's one line on what changed.
|
|
22
|
+
note: string | null;
|
|
23
|
+
bytes: number | null;
|
|
24
|
+
sha256: string | null;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
export interface SaveOptions {
|
|
28
|
+
// Any version's id of a page in this session: add a version to it.
|
|
29
|
+
update?: string;
|
|
30
|
+
// With no update, the newest page in this session with the same title.
|
|
31
|
+
sameTitle?: boolean;
|
|
32
|
+
note?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Saved {
|
|
36
|
+
artifact: Artifact;
|
|
37
|
+
// False when the page was byte-for-byte its latest version already.
|
|
38
|
+
created: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MAX_NOTE_CHARS = 120;
|
|
42
|
+
|
|
20
43
|
export const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
|
21
44
|
export const MAX_TITLE_CHARS = 120;
|
|
22
45
|
|
|
@@ -35,20 +58,92 @@ function sessionDir(sessionId: string): string {
|
|
|
35
58
|
export function createArtifact(
|
|
36
59
|
sessionId: string,
|
|
37
60
|
title: string,
|
|
38
|
-
html: string
|
|
61
|
+
html: string,
|
|
62
|
+
opts: SaveOptions = {}
|
|
39
63
|
): Artifact {
|
|
40
|
-
|
|
64
|
+
return saveArtifact(sessionId, title, html, opts).artifact;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const oneLine = (s: string, max: number) =>
|
|
68
|
+
s.replace(/\s+/g, " ").trim().slice(0, max);
|
|
69
|
+
|
|
70
|
+
// Saves a page, or a new version of one. Earlier versions are never touched.
|
|
71
|
+
export function saveArtifact(
|
|
72
|
+
sessionId: string,
|
|
73
|
+
title: string,
|
|
74
|
+
html: string,
|
|
75
|
+
opts: SaveOptions = {}
|
|
76
|
+
): Saved {
|
|
77
|
+
const bytes = Buffer.byteLength(html, "utf8");
|
|
78
|
+
if (bytes > MAX_HTML_BYTES)
|
|
41
79
|
throw new Error("The page is over 2 MB; make it smaller.");
|
|
80
|
+
const clean = oneLine(title, MAX_TITLE_CHARS) || "Untitled";
|
|
81
|
+
const note = opts.note ? oneLine(opts.note, MAX_NOTE_CHARS) || null : null;
|
|
82
|
+
const sha256 = createHash("sha256").update(html).digest("hex");
|
|
42
83
|
const id = randomUUID();
|
|
43
84
|
const dir = sessionDir(sessionId);
|
|
44
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
85
|
const file = path.join(dir, `${id}.html`);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
return db.transaction((): Saved => {
|
|
87
|
+
const pageId = resolvePage(sessionId, clean, opts);
|
|
88
|
+
const latest = pageId ? latestVersion(pageId) : undefined;
|
|
89
|
+
if (latest && latest.sha256 === sha256)
|
|
90
|
+
return { artifact: latest, created: false };
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
92
|
+
fs.writeFileSync(file, html, { mode: 0o600 });
|
|
93
|
+
db.prepare(
|
|
94
|
+
`INSERT INTO artifacts (id, session_id, title, path, page_id, version, note, bytes, sha256)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
96
|
+
).run(
|
|
97
|
+
id,
|
|
98
|
+
sessionId,
|
|
99
|
+
clean,
|
|
100
|
+
file,
|
|
101
|
+
pageId ?? id,
|
|
102
|
+
latest ? latest.version + 1 : 1,
|
|
103
|
+
note,
|
|
104
|
+
bytes,
|
|
105
|
+
sha256
|
|
106
|
+
);
|
|
107
|
+
return { artifact: getArtifact(id)!, created: true };
|
|
108
|
+
})();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolvePage(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
title: string,
|
|
114
|
+
opts: SaveOptions
|
|
115
|
+
): string | undefined {
|
|
116
|
+
if (opts.update) {
|
|
117
|
+
const base = getArtifact(opts.update);
|
|
118
|
+
if (!base || base.session_id !== sessionId)
|
|
119
|
+
throw new Error(
|
|
120
|
+
`No page ${opts.update} in this chat; leave out update to show a new page.`
|
|
121
|
+
);
|
|
122
|
+
return base.page_id;
|
|
123
|
+
}
|
|
124
|
+
if (!opts.sameTitle) return undefined;
|
|
125
|
+
const row = db
|
|
126
|
+
.prepare(
|
|
127
|
+
`SELECT page_id FROM artifacts WHERE session_id = ? AND lower(title) = lower(?)
|
|
128
|
+
ORDER BY created_at DESC, rowid DESC LIMIT 1`
|
|
129
|
+
)
|
|
130
|
+
.get(sessionId, title) as { page_id: string } | undefined;
|
|
131
|
+
return row?.page_id;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Every version of a page, oldest first.
|
|
135
|
+
export function listVersions(pageId: string): Artifact[] {
|
|
136
|
+
return db
|
|
137
|
+
.prepare(`SELECT * FROM artifacts WHERE page_id = ? ORDER BY version`)
|
|
138
|
+
.all(pageId) as Artifact[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function latestVersion(pageId: string): Artifact | undefined {
|
|
142
|
+
return db
|
|
143
|
+
.prepare(
|
|
144
|
+
`SELECT * FROM artifacts WHERE page_id = ? ORDER BY version DESC LIMIT 1`
|
|
145
|
+
)
|
|
146
|
+
.get(pageId) as Artifact | undefined;
|
|
52
147
|
}
|
|
53
148
|
|
|
54
149
|
export function getArtifact(id: string): Artifact | undefined {
|
package/lib/artifacts/tools.ts
CHANGED
|
@@ -13,11 +13,13 @@ import {
|
|
|
13
13
|
type McpSdkServerConfigWithInstance,
|
|
14
14
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
15
15
|
import {
|
|
16
|
-
|
|
16
|
+
saveArtifact,
|
|
17
17
|
MAX_HTML_BYTES,
|
|
18
|
+
MAX_NOTE_CHARS,
|
|
18
19
|
MAX_TITLE_CHARS,
|
|
19
20
|
type Artifact,
|
|
20
21
|
} from "./store";
|
|
22
|
+
import { followLatest } from "../share/follow";
|
|
21
23
|
import { previewHtml } from "./preview";
|
|
22
24
|
|
|
23
25
|
export const VISUALS_SERVER = "visuals";
|
|
@@ -84,20 +86,34 @@ export interface VisualsContext {
|
|
|
84
86
|
// Saves the page and shows it in the conversation.
|
|
85
87
|
export async function renderPage(
|
|
86
88
|
ctx: VisualsContext,
|
|
87
|
-
args: {
|
|
89
|
+
args: {
|
|
90
|
+
html?: string;
|
|
91
|
+
path?: string;
|
|
92
|
+
title: string;
|
|
93
|
+
height?: number;
|
|
94
|
+
update?: string;
|
|
95
|
+
note?: string;
|
|
96
|
+
}
|
|
88
97
|
): Promise<CallToolResult> {
|
|
89
98
|
try {
|
|
90
|
-
const artifact =
|
|
99
|
+
const { artifact, created } = saveArtifact(
|
|
91
100
|
ctx.sessionId,
|
|
92
101
|
args.title,
|
|
93
|
-
readSource(args, ctx.cwd)
|
|
102
|
+
readSource(args, ctx.cwd),
|
|
103
|
+
{ update: args.update, note: args.note, sameTitle: true }
|
|
94
104
|
);
|
|
95
|
-
|
|
105
|
+
if (created) {
|
|
106
|
+
ctx.onRender(artifact, args.height);
|
|
107
|
+
if (artifact.version > 1) followLatest(artifact.page_id);
|
|
108
|
+
}
|
|
109
|
+
const name = `"${artifact.title}"${artifact.version > 1 ? ` v${artifact.version}` : ""}`;
|
|
96
110
|
return {
|
|
97
111
|
content: [
|
|
98
112
|
{
|
|
99
113
|
type: "text",
|
|
100
|
-
text:
|
|
114
|
+
text: created
|
|
115
|
+
? `Shown in the chat as ${name} (artifact ${artifact.id}). To change it, call html_render again with update: "${artifact.id}"; every version is kept.`
|
|
116
|
+
: `Unchanged: the page is the same as ${name} (artifact ${artifact.id}), already in the chat.`,
|
|
101
117
|
},
|
|
102
118
|
],
|
|
103
119
|
};
|
|
@@ -171,6 +187,17 @@ export function visualsTools(
|
|
|
171
187
|
.describe(
|
|
172
188
|
"Cap on the frame's height in CSS pixels; longer pages scroll inside it. Defaults to fitting the page."
|
|
173
189
|
),
|
|
190
|
+
update: z
|
|
191
|
+
.string()
|
|
192
|
+
.optional()
|
|
193
|
+
.describe(
|
|
194
|
+
"The artifact id of a page you already showed in this chat, to show a new version of it. A page with the same title is updated too. Every version is kept."
|
|
195
|
+
),
|
|
196
|
+
note: z
|
|
197
|
+
.string()
|
|
198
|
+
.max(MAX_NOTE_CHARS)
|
|
199
|
+
.optional()
|
|
200
|
+
.describe("For a new version: one line on what changed."),
|
|
174
201
|
},
|
|
175
202
|
(args) => renderPage(ctx, args)
|
|
176
203
|
),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A page's versions for the version switcher: each with its note, size and
|
|
2
|
+
// the message that asked for it.
|
|
3
|
+
|
|
4
|
+
import { db } from "../db";
|
|
5
|
+
import { getArtifact, listVersions, type Artifact } from "./store";
|
|
6
|
+
import {
|
|
7
|
+
pageActions,
|
|
8
|
+
type ArtifactVersion,
|
|
9
|
+
type PageVersions,
|
|
10
|
+
} from "./actions";
|
|
11
|
+
|
|
12
|
+
const PROMPT_CHARS = 80;
|
|
13
|
+
|
|
14
|
+
const sqliteMs = (t: string) =>
|
|
15
|
+
Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`) || 0;
|
|
16
|
+
|
|
17
|
+
// The user message before the chat item that showed this version.
|
|
18
|
+
function turnOf(a: Artifact): ArtifactVersion["turn"] {
|
|
19
|
+
const row = db
|
|
20
|
+
.prepare(
|
|
21
|
+
`SELECT u.item_id AS itemId,
|
|
22
|
+
json_extract(u.data, '$.text') AS text,
|
|
23
|
+
(SELECT COUNT(*) FROM chat_items c
|
|
24
|
+
WHERE c.session_id = u.session_id AND c.kind = 'user'
|
|
25
|
+
AND c.seq <= u.seq) AS idx
|
|
26
|
+
FROM chat_items u
|
|
27
|
+
WHERE u.session_id = ? AND u.kind = 'user'
|
|
28
|
+
AND u.seq < (SELECT seq FROM chat_items
|
|
29
|
+
WHERE session_id = ? AND item_id = ?)
|
|
30
|
+
ORDER BY u.seq DESC LIMIT 1`
|
|
31
|
+
)
|
|
32
|
+
.get(a.session_id, a.session_id, `artifact-${a.id}`) as
|
|
33
|
+
{ itemId: string; text: string | null; idx: number } | undefined;
|
|
34
|
+
if (!row) return undefined;
|
|
35
|
+
const prompt = (row.text ?? "").replace(/\s+/g, " ").trim();
|
|
36
|
+
return {
|
|
37
|
+
itemId: row.itemId,
|
|
38
|
+
index: row.idx,
|
|
39
|
+
prompt:
|
|
40
|
+
prompt.length > PROMPT_CHARS
|
|
41
|
+
? `${prompt.slice(0, PROMPT_CHARS - 1)}…`
|
|
42
|
+
: prompt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function toVersion(a: Artifact): ArtifactVersion {
|
|
47
|
+
const turn = turnOf(a);
|
|
48
|
+
return {
|
|
49
|
+
id: a.id,
|
|
50
|
+
version: a.version,
|
|
51
|
+
title: a.title,
|
|
52
|
+
...(a.note && { note: a.note }),
|
|
53
|
+
createdAt: sqliteMs(a.created_at),
|
|
54
|
+
...(a.bytes != null && { bytes: a.bytes }),
|
|
55
|
+
...(turn && { turn }),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Every version of the page that artifactId is a version of.
|
|
60
|
+
export function pageVersions(
|
|
61
|
+
artifactId: string,
|
|
62
|
+
ctx: { pdf: boolean }
|
|
63
|
+
): PageVersions | undefined {
|
|
64
|
+
const a = getArtifact(artifactId);
|
|
65
|
+
if (!a) return undefined;
|
|
66
|
+
const versions = listVersions(a.page_id).map(toVersion);
|
|
67
|
+
return {
|
|
68
|
+
pageId: a.page_id,
|
|
69
|
+
latestId: versions[versions.length - 1]?.id ?? a.id,
|
|
70
|
+
versions,
|
|
71
|
+
actions: pageActions(ctx),
|
|
72
|
+
};
|
|
73
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -254,6 +254,10 @@ export type ChatItem =
|
|
|
254
254
|
kind: "artifact";
|
|
255
255
|
artifactId: string;
|
|
256
256
|
title: string;
|
|
257
|
+
// The page it's a version of, and which; absent before versions (v1 of
|
|
258
|
+
// its own page).
|
|
259
|
+
pageId?: string;
|
|
260
|
+
version?: number;
|
|
257
261
|
// A cap on the frame's height; it fits the page otherwise.
|
|
258
262
|
height?: number;
|
|
259
263
|
})
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -428,6 +428,21 @@ export async function uploadShare(
|
|
|
428
428
|
return { ...shareFrom(r), url };
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
/** Replaces a live share's content under the same link. */
|
|
432
|
+
export async function replaceShare(
|
|
433
|
+
config: ConnectConfig,
|
|
434
|
+
privateKey: string,
|
|
435
|
+
id: string,
|
|
436
|
+
share: { title: string; html: string; markdown: string }
|
|
437
|
+
): Promise<void> {
|
|
438
|
+
if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
|
|
439
|
+
await signed(config, privateKey)(
|
|
440
|
+
"PUT",
|
|
441
|
+
`/api/v1/machines/${config.machineId}/shares/${id}`,
|
|
442
|
+
{ title: share.title, html: share.html, markdown: share.markdown }
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
431
446
|
export async function listShares(
|
|
432
447
|
config: ConnectConfig,
|
|
433
448
|
privateKey: string
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261029T1200_artifact_versions",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Each artifacts row is one version of a page; rows sharing page_id are
|
|
7
|
+
// that page's history. Versions are only ever added, never rewritten.
|
|
8
|
+
const cols = new Set(
|
|
9
|
+
(
|
|
10
|
+
db.prepare(`PRAGMA table_info(artifacts)`).all() as { name: string }[]
|
|
11
|
+
).map((c) => c.name)
|
|
12
|
+
);
|
|
13
|
+
const add = (name: string, type: string) => {
|
|
14
|
+
if (!cols.has(name))
|
|
15
|
+
db.exec(`ALTER TABLE artifacts ADD COLUMN ${name} ${type}`);
|
|
16
|
+
};
|
|
17
|
+
add("page_id", "TEXT");
|
|
18
|
+
add("version", "INTEGER NOT NULL DEFAULT 1");
|
|
19
|
+
// The agent's one line on what changed.
|
|
20
|
+
add("note", "TEXT");
|
|
21
|
+
add("bytes", "INTEGER");
|
|
22
|
+
add("sha256", "TEXT");
|
|
23
|
+
db.exec(`UPDATE artifacts SET page_id = id WHERE page_id IS NULL`);
|
|
24
|
+
db.exec(
|
|
25
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_page_version ON artifacts(page_id, version)`
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261029T1300_shared_links_follow",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// follow_latest: the link is re-uploaded whenever its page gets a new
|
|
7
|
+
// version. version: the page version the link shows now (pinned or not).
|
|
8
|
+
const cols = new Set(
|
|
9
|
+
(
|
|
10
|
+
db.prepare(`PRAGMA table_info(shared_links)`).all() as {
|
|
11
|
+
name: string;
|
|
12
|
+
}[]
|
|
13
|
+
).map((c) => c.name)
|
|
14
|
+
);
|
|
15
|
+
if (!cols.has("follow_latest"))
|
|
16
|
+
db.exec(
|
|
17
|
+
`ALTER TABLE shared_links ADD COLUMN follow_latest INTEGER NOT NULL DEFAULT 0`
|
|
18
|
+
);
|
|
19
|
+
if (!cols.has("version"))
|
|
20
|
+
db.exec(`ALTER TABLE shared_links ADD COLUMN version INTEGER`);
|
|
21
|
+
// Links made before this show the version their artifact row is.
|
|
22
|
+
db.exec(
|
|
23
|
+
`UPDATE shared_links SET version = (
|
|
24
|
+
SELECT a.version FROM artifacts a WHERE a.id = shared_links.artifact_id
|
|
25
|
+
) WHERE version IS NULL AND artifact_id IS NOT NULL`
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
} satisfies Migration;
|
package/lib/security/demo.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { db, queries, type Session } from "../db";
|
|
|
12
12
|
import { flushItems } from "../chat/store";
|
|
13
13
|
import { git } from "../git-exec";
|
|
14
14
|
import { PLAN_FILE } from "../models/plan-step";
|
|
15
|
-
import { listArtifacts } from "../artifacts/store";
|
|
15
|
+
import { listArtifacts, type Artifact } from "../artifacts/store";
|
|
16
16
|
import { allowedRoots, within } from "./access";
|
|
17
17
|
import { SCREENSHOT_DIR } from "../tasks/ui-brief";
|
|
18
18
|
import {
|
|
@@ -51,6 +51,7 @@ const ITEMS_SQL = `
|
|
|
51
51
|
json_extract(data, '$.input.filePath') AS file_path_camel,
|
|
52
52
|
json_extract(data, '$.input.changes') AS changes,
|
|
53
53
|
json_extract(data, '$.artifactId') AS artifact_id,
|
|
54
|
+
json_extract(data, '$.pageId') AS page_id,
|
|
54
55
|
json_extract(data, '$.title') AS title,
|
|
55
56
|
json_extract(data, '$.taskId') AS task_id,
|
|
56
57
|
json_extract(data, '$.outputFile') AS output_file,
|
|
@@ -202,16 +203,37 @@ async function build(session: Session): Promise<Entry[]> {
|
|
|
202
203
|
const keptBase = sessionKeptDir(session.id);
|
|
203
204
|
const tracked = new Set(manifest?.tracked);
|
|
204
205
|
const kept = new Set(manifest?.kept);
|
|
205
|
-
const
|
|
206
|
+
const saved = listArtifacts(session.id);
|
|
207
|
+
const byId = new Map(saved.map((a) => [a.id, a]));
|
|
208
|
+
// Each page's newest version and how many it has.
|
|
209
|
+
const newest = new Map<string, Artifact>();
|
|
210
|
+
const count = new Map<string, number>();
|
|
211
|
+
for (const a of saved) {
|
|
212
|
+
count.set(a.page_id, (count.get(a.page_id) ?? 0) + 1);
|
|
213
|
+
if ((newest.get(a.page_id)?.version ?? 0) < a.version)
|
|
214
|
+
newest.set(a.page_id, a);
|
|
215
|
+
}
|
|
206
216
|
const entries = await Promise.all(
|
|
207
217
|
candidates.map(async (c): Promise<Entry> => {
|
|
208
218
|
if (c.source.type === "page") {
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
219
|
+
const seen = byId.get(c.source.artifactId);
|
|
220
|
+
const page = seen && (newest.get(seen.page_id) ?? seen);
|
|
221
|
+
const size = page ? await sizeOf(page.path) : undefined;
|
|
222
|
+
const c2 = page
|
|
223
|
+
? { ...c, source: { type: "page" as const, artifactId: page.id } }
|
|
224
|
+
: c;
|
|
225
|
+
const entry = toArtifact(
|
|
226
|
+
c2,
|
|
213
227
|
size === undefined ? { state: "gone" } : { state: "live", size }
|
|
214
228
|
);
|
|
229
|
+
if (page)
|
|
230
|
+
Object.assign(entry.artifact, {
|
|
231
|
+
name: page.title,
|
|
232
|
+
artifactId: page.id,
|
|
233
|
+
version: page.version,
|
|
234
|
+
versionCount: count.get(page.page_id) ?? 1,
|
|
235
|
+
});
|
|
236
|
+
return entry;
|
|
215
237
|
}
|
|
216
238
|
const file = filePathOf(c);
|
|
217
239
|
if (!file) return toArtifact(c, null);
|
|
@@ -29,6 +29,7 @@ export interface ItemRow {
|
|
|
29
29
|
file_path_camel?: string | null;
|
|
30
30
|
changes?: string | null;
|
|
31
31
|
artifact_id?: string | null;
|
|
32
|
+
page_id?: string | null;
|
|
32
33
|
title?: string | null;
|
|
33
34
|
task_id?: string | null;
|
|
34
35
|
output_file?: string | null;
|
|
@@ -177,7 +178,8 @@ export function fromItems(
|
|
|
177
178
|
});
|
|
178
179
|
}
|
|
179
180
|
} else if (row.kind === "artifact" && row.artifact_id) {
|
|
180
|
-
|
|
181
|
+
// One row per page: a later version replaces an earlier one.
|
|
182
|
+
const key = `page:${row.page_id || row.artifact_id}`;
|
|
181
183
|
out.set(key, {
|
|
182
184
|
key,
|
|
183
185
|
kind: "page",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// What a session produced, as the Artifacts view lists it. Client-safe.
|
|
2
2
|
|
|
3
|
+
import type { ArtifactAction } from "../artifacts/actions";
|
|
4
|
+
|
|
3
5
|
export type ArtifactKind =
|
|
4
6
|
"plan" | "file" | "page" | "image" | "log" | "pr" | "run" | "session-log";
|
|
5
7
|
|
|
@@ -30,6 +32,13 @@ export interface SessionArtifact {
|
|
|
30
32
|
folder?: string;
|
|
31
33
|
// A link out (the PR on GitHub).
|
|
32
34
|
url?: string;
|
|
35
|
+
// A page: its latest version's artifact id, which version that is, and
|
|
36
|
+
// how many there are.
|
|
37
|
+
artifactId?: string;
|
|
38
|
+
version?: number;
|
|
39
|
+
versionCount?: number;
|
|
40
|
+
// What the viewer can do with it; set by the server.
|
|
41
|
+
actions?: ArtifactAction[];
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export const openable = (a: Pick<SessionArtifact, "state" | "preview">) =>
|
package/lib/share/connect.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ApiError,
|
|
6
6
|
clean,
|
|
7
7
|
listShares,
|
|
8
|
+
replaceShare,
|
|
8
9
|
revokeShare,
|
|
9
10
|
uploadShare,
|
|
10
11
|
type RemoteShare,
|
|
@@ -70,6 +71,15 @@ export async function createRemote(share: {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** Puts new content under a live link. Throws the raw ApiError, so the caller can tell a dead link from a failed try. */
|
|
75
|
+
export async function replaceRemote(
|
|
76
|
+
id: string,
|
|
77
|
+
content: { title: string; html: string; markdown: string }
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const { config, machineKey } = files();
|
|
80
|
+
await replaceShare(config, machineKey, id, content);
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
export async function listRemote(): Promise<RemoteShare[]> {
|
|
74
84
|
const { config, machineKey } = files();
|
|
75
85
|
return listShares(config, machineKey);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Follow-latest share links: when a page gets a new version, every live link
|
|
3
|
+
* that follows it is re-uploaded through Connect's PUT, built the same way as
|
|
4
|
+
* a normal share (same snapshot, same redaction). Nothing here throws into
|
|
5
|
+
* the caller: a failed try is logged and the next version tries again, and
|
|
6
|
+
* the content already on the link stays live meanwhile.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ApiError } from "@/lib/connect/control-api";
|
|
10
|
+
import { latestVersion } from "@/lib/artifacts/store";
|
|
11
|
+
import { linked, replaceRemote } from "./connect";
|
|
12
|
+
import { BadShare, buildPage } from "./service";
|
|
13
|
+
import { listFollowing, setLinkVersion, stopFollowing } from "./store";
|
|
14
|
+
|
|
15
|
+
// One run per page at a time, so two quick versions can't land out of order.
|
|
16
|
+
const running = new Map<string, Promise<void>>();
|
|
17
|
+
|
|
18
|
+
export function followLatest(pageId: string): Promise<void> {
|
|
19
|
+
const next = (running.get(pageId) ?? Promise.resolve())
|
|
20
|
+
.then(() => run(pageId))
|
|
21
|
+
.catch((err) => console.error("[share-follow]", pageId, err))
|
|
22
|
+
.finally(() => {
|
|
23
|
+
if (running.get(pageId) === next) running.delete(pageId);
|
|
24
|
+
});
|
|
25
|
+
running.set(pageId, next);
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run(pageId: string): Promise<void> {
|
|
30
|
+
const latest = latestVersion(pageId);
|
|
31
|
+
if (!latest) return;
|
|
32
|
+
// A link that missed an earlier version is behind too, so this is also the retry.
|
|
33
|
+
const behind = listFollowing(pageId).filter(
|
|
34
|
+
(l) => (l.version ?? 0) < latest.version
|
|
35
|
+
);
|
|
36
|
+
if (!behind.length || !linked()) return;
|
|
37
|
+
let page;
|
|
38
|
+
try {
|
|
39
|
+
page = await buildPage({
|
|
40
|
+
kind: "artifact",
|
|
41
|
+
artifactId: latest.id,
|
|
42
|
+
includeTools: false,
|
|
43
|
+
sharedAt: new Date().toISOString(),
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(
|
|
47
|
+
"[share-follow] could not build",
|
|
48
|
+
latest.id,
|
|
49
|
+
err instanceof BadShare ? err.message : err
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (page.tooBig) {
|
|
54
|
+
console.error("[share-follow] v%d is too big to share", latest.version);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const link of behind) {
|
|
58
|
+
try {
|
|
59
|
+
await replaceRemote(link.id, {
|
|
60
|
+
title: page.title,
|
|
61
|
+
html: page.html,
|
|
62
|
+
markdown: page.markdown,
|
|
63
|
+
});
|
|
64
|
+
setLinkVersion(link.id, latest.version, page.title);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof ApiError && (err.status === 404 || err.status === 410))
|
|
67
|
+
stopFollowing(link.id);
|
|
68
|
+
console.error(
|
|
69
|
+
"[share-follow] %s stays on v%s:",
|
|
70
|
+
link.id,
|
|
71
|
+
link.version,
|
|
72
|
+
err instanceof Error ? err.message : err
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
package/lib/share/service.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface ShareRequest {
|
|
|
25
25
|
includeTools: boolean;
|
|
26
26
|
/** Messages the reader left out in the preview. */
|
|
27
27
|
dropIds?: string[];
|
|
28
|
+
follow?: "latest";
|
|
28
29
|
sharedAt: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -86,6 +87,10 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
|
|
|
86
87
|
throw new BadShare("dropIds must be a list of ids", 400);
|
|
87
88
|
dropIds = b.dropIds as string[];
|
|
88
89
|
}
|
|
90
|
+
if (b.follow !== undefined && b.follow !== "latest")
|
|
91
|
+
throw new BadShare("follow must be latest", 400);
|
|
92
|
+
if (b.follow && kind !== "artifact")
|
|
93
|
+
throw new BadShare("Only a page can follow its latest version", 400);
|
|
89
94
|
const at = typeof sharedAt === "string" ? sharedAt : new Date().toISOString();
|
|
90
95
|
if (Number.isNaN(Date.parse(at)))
|
|
91
96
|
throw new BadShare("sharedAt is not a date", 400);
|
|
@@ -95,6 +100,7 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
|
|
|
95
100
|
artifactId,
|
|
96
101
|
includeTools: b.includeTools === true,
|
|
97
102
|
dropIds,
|
|
103
|
+
follow: b.follow === "latest" ? "latest" : undefined,
|
|
98
104
|
sharedAt: at,
|
|
99
105
|
};
|
|
100
106
|
}
|
package/lib/share/store.ts
CHANGED
|
@@ -10,13 +10,16 @@ export interface SharedLink {
|
|
|
10
10
|
expires_at: string | null;
|
|
11
11
|
created_at: string;
|
|
12
12
|
revoked_at: string | null;
|
|
13
|
+
follow_latest: number;
|
|
14
|
+
// The page version the link shows now.
|
|
15
|
+
version: number | null;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export function insertSharedLink(link: SharedLink): void {
|
|
16
19
|
db.prepare(
|
|
17
20
|
`INSERT INTO shared_links
|
|
18
|
-
(id, url, kind, title, session_id, artifact_id, expires_at, created_at, revoked_at)
|
|
19
|
-
VALUES (@id, @url, @kind, @title, @session_id, @artifact_id, @expires_at, @created_at, @revoked_at)`
|
|
21
|
+
(id, url, kind, title, session_id, artifact_id, expires_at, created_at, revoked_at, follow_latest, version)
|
|
22
|
+
VALUES (@id, @url, @kind, @title, @session_id, @artifact_id, @expires_at, @created_at, @revoked_at, @follow_latest, @version)`
|
|
20
23
|
).run(link);
|
|
21
24
|
}
|
|
22
25
|
|
|
@@ -36,3 +39,32 @@ export function markRevoked(id: string): void {
|
|
|
36
39
|
`UPDATE shared_links SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`
|
|
37
40
|
).run(new Date().toISOString(), id);
|
|
38
41
|
}
|
|
42
|
+
|
|
43
|
+
/** Live follow-latest links to any version of a page. */
|
|
44
|
+
export function listFollowing(pageId: string): SharedLink[] {
|
|
45
|
+
return db
|
|
46
|
+
.prepare(
|
|
47
|
+
`SELECT l.* FROM shared_links l JOIN artifacts a ON a.id = l.artifact_id
|
|
48
|
+
WHERE a.page_id = ? AND l.follow_latest = 1 AND l.revoked_at IS NULL
|
|
49
|
+
AND (l.expires_at IS NULL OR l.expires_at > ?)
|
|
50
|
+
ORDER BY l.created_at, l.rowid`
|
|
51
|
+
)
|
|
52
|
+
.all(pageId, new Date().toISOString()) as SharedLink[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setLinkVersion(
|
|
56
|
+
id: string,
|
|
57
|
+
version: number,
|
|
58
|
+
title: string
|
|
59
|
+
): void {
|
|
60
|
+
db.prepare(`UPDATE shared_links SET version = ?, title = ? WHERE id = ?`).run(
|
|
61
|
+
version,
|
|
62
|
+
title,
|
|
63
|
+
id
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Connect no longer has it: stop trying. */
|
|
68
|
+
export function stopFollowing(id: string): void {
|
|
69
|
+
db.prepare(`UPDATE shared_links SET follow_latest = 0 WHERE id = ?`).run(id);
|
|
70
|
+
}
|