@ixo/oracle-runtime-workers 0.13.0 → 0.15.0
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/README.md +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +267 -0
- package/src/channels/contract.ts +137 -0
- package/src/channels/turns.ts +270 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +1883 -264
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +529 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artefacts: long chat output kept as a document the user opens in a browser.
|
|
3
|
+
*
|
|
4
|
+
* The canonical copy is a row in the user's own SQLite, so it travels with
|
|
5
|
+
* the working copy into the user's VFS file like the rest of their history.
|
|
6
|
+
* The share copy is ciphertext in R2 under `art/<id>`, expiring with the
|
|
7
|
+
* link; the key lives in the row and in the link's fragment. Creation is
|
|
8
|
+
* idempotent per id, which is what lets a recovered run rebuild its reply
|
|
9
|
+
* without minting a second document.
|
|
10
|
+
*/
|
|
11
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
12
|
+
import type { ArtifactRef } from '../delivery/types';
|
|
13
|
+
import { artifactLink, type ArtifactStorageConfig } from './config';
|
|
14
|
+
import { newShareKey, sealArtifact } from './crypto';
|
|
15
|
+
|
|
16
|
+
export const ARTIFACT_ID_RE = /^[0-9a-f]{32}$/;
|
|
17
|
+
|
|
18
|
+
/** Every share copy lives under this R2 prefix, and nothing else does. */
|
|
19
|
+
export const ARTIFACT_OBJECT_PREFIX = 'art/';
|
|
20
|
+
|
|
21
|
+
export function artifactObjectKey(artifactId: string): string {
|
|
22
|
+
return `${ARTIFACT_OBJECT_PREFIX}${artifactId}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A stable id for (run, source): the tool call id, or a spill's step key. */
|
|
26
|
+
export async function artifactIdFor(
|
|
27
|
+
runId: string,
|
|
28
|
+
source: string,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const digest = await crypto.subtle.digest(
|
|
31
|
+
'SHA-256',
|
|
32
|
+
new TextEncoder().encode(`artifact\0${runId}\0${source}`),
|
|
33
|
+
);
|
|
34
|
+
return Array.from(new Uint8Array(digest).slice(0, 16), (b) =>
|
|
35
|
+
b.toString(16).padStart(2, '0'),
|
|
36
|
+
).join('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ArtifactRow = {
|
|
40
|
+
artifact_id: string;
|
|
41
|
+
session_id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
content: string;
|
|
44
|
+
bytes: number;
|
|
45
|
+
share_key: string;
|
|
46
|
+
created_at: string;
|
|
47
|
+
expires_at: string;
|
|
48
|
+
uploaded: number;
|
|
49
|
+
revoked_at: string | null;
|
|
50
|
+
} & Record<string, string | number | null>;
|
|
51
|
+
|
|
52
|
+
export interface StoredArtifact {
|
|
53
|
+
/** `url` is absent while no public origin is configured. */
|
|
54
|
+
ref: Omit<ArtifactRef, 'url'> & { url?: string };
|
|
55
|
+
sessionId: string;
|
|
56
|
+
content: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
revoked: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** An artefact as its owner reads it: `url` only while the link works. */
|
|
62
|
+
export interface OwnedArtifact extends Omit<ArtifactRef, 'url'> {
|
|
63
|
+
url?: string;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
revoked: boolean;
|
|
67
|
+
content: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ArtifactStore {
|
|
71
|
+
private setupPromise: Promise<void> | undefined;
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly db: DoSqliteDatabase,
|
|
75
|
+
private readonly config: ArtifactStorageConfig,
|
|
76
|
+
private readonly now: () => number = () => Date.now(),
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
/** Whether new artefacts can be created: a public origin for their links is set. */
|
|
80
|
+
get canShare(): boolean {
|
|
81
|
+
return this.config.links !== null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setup(): Promise<void> {
|
|
85
|
+
this.setupPromise ??= this.db
|
|
86
|
+
.run(
|
|
87
|
+
`CREATE TABLE IF NOT EXISTS artifacts (
|
|
88
|
+
artifact_id TEXT PRIMARY KEY,
|
|
89
|
+
session_id TEXT NOT NULL,
|
|
90
|
+
run_id TEXT NOT NULL,
|
|
91
|
+
title TEXT NOT NULL,
|
|
92
|
+
mime TEXT NOT NULL,
|
|
93
|
+
content TEXT NOT NULL,
|
|
94
|
+
bytes INTEGER NOT NULL,
|
|
95
|
+
share_key TEXT NOT NULL,
|
|
96
|
+
created_at TEXT NOT NULL,
|
|
97
|
+
expires_at TEXT NOT NULL,
|
|
98
|
+
uploaded INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
revoked_at TEXT
|
|
100
|
+
)`,
|
|
101
|
+
)
|
|
102
|
+
.then(() => undefined)
|
|
103
|
+
.catch((error: unknown) => {
|
|
104
|
+
this.setupPromise = undefined;
|
|
105
|
+
throw error;
|
|
106
|
+
});
|
|
107
|
+
return this.setupPromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private refOf(row: ArtifactRow): StoredArtifact['ref'] {
|
|
111
|
+
const links = this.config.links;
|
|
112
|
+
return {
|
|
113
|
+
artifactId: row.artifact_id,
|
|
114
|
+
title: row.title,
|
|
115
|
+
...(links
|
|
116
|
+
? { url: artifactLink(links, row.artifact_id, row.share_key) }
|
|
117
|
+
: {}),
|
|
118
|
+
mime: 'text/markdown',
|
|
119
|
+
bytes: Number(row.bytes),
|
|
120
|
+
expiresAt: row.expires_at,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private row(artifactId: string): Promise<ArtifactRow | undefined> {
|
|
125
|
+
return this.db.get<ArtifactRow>(
|
|
126
|
+
`SELECT artifact_id, session_id, title, content, bytes, share_key,
|
|
127
|
+
created_at, expires_at, uploaded, revoked_at
|
|
128
|
+
FROM artifacts WHERE artifact_id = ?`,
|
|
129
|
+
[artifactId],
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Create (or return the existing) artefact and make sure its share copy exists. */
|
|
134
|
+
async create(input: {
|
|
135
|
+
artifactId: string;
|
|
136
|
+
sessionId: string;
|
|
137
|
+
runId: string;
|
|
138
|
+
title: string;
|
|
139
|
+
content: string;
|
|
140
|
+
}): Promise<ArtifactRef> {
|
|
141
|
+
const links = this.config.links;
|
|
142
|
+
if (!links)
|
|
143
|
+
throw new Error(
|
|
144
|
+
'artifacts: no public origin (ORACLE_PUBLIC_URL) for share links',
|
|
145
|
+
);
|
|
146
|
+
await this.setup();
|
|
147
|
+
const createdAt = new Date(this.now());
|
|
148
|
+
await this.db.run(
|
|
149
|
+
`INSERT OR IGNORE INTO artifacts (artifact_id, session_id, run_id, title,
|
|
150
|
+
mime, content, bytes, share_key, created_at, expires_at)
|
|
151
|
+
VALUES (?, ?, ?, ?, 'text/markdown', ?, ?, ?, ?, ?)`,
|
|
152
|
+
[
|
|
153
|
+
input.artifactId,
|
|
154
|
+
input.sessionId,
|
|
155
|
+
input.runId,
|
|
156
|
+
input.title,
|
|
157
|
+
input.content,
|
|
158
|
+
new TextEncoder().encode(input.content).byteLength,
|
|
159
|
+
newShareKey(),
|
|
160
|
+
createdAt.toISOString(),
|
|
161
|
+
new Date(createdAt.getTime() + this.config.ttlMs).toISOString(),
|
|
162
|
+
],
|
|
163
|
+
);
|
|
164
|
+
const row = await this.row(input.artifactId);
|
|
165
|
+
if (!row) throw new Error(`artifacts: ${input.artifactId} was not stored`);
|
|
166
|
+
if (!Number(row.uploaded) && !row.revoked_at) {
|
|
167
|
+
await this.config.bucket.put(
|
|
168
|
+
artifactObjectKey(row.artifact_id),
|
|
169
|
+
await sealArtifact(row.share_key, {
|
|
170
|
+
v: 1,
|
|
171
|
+
title: row.title,
|
|
172
|
+
mime: 'text/markdown',
|
|
173
|
+
content: row.content,
|
|
174
|
+
createdAt: row.created_at,
|
|
175
|
+
}),
|
|
176
|
+
{
|
|
177
|
+
httpMetadata: { contentType: 'application/octet-stream' },
|
|
178
|
+
customMetadata: { expiresAt: row.expires_at },
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
await this.db.run(
|
|
182
|
+
'UPDATE artifacts SET uploaded = 1 WHERE artifact_id = ?',
|
|
183
|
+
[row.artifact_id],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
...this.refOf(row),
|
|
188
|
+
url: artifactLink(links, row.artifact_id, row.share_key),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async get(artifactId: string): Promise<StoredArtifact | undefined> {
|
|
193
|
+
await this.setup();
|
|
194
|
+
const row = await this.row(artifactId);
|
|
195
|
+
if (!row) return undefined;
|
|
196
|
+
return {
|
|
197
|
+
ref: this.refOf(row),
|
|
198
|
+
sessionId: row.session_id,
|
|
199
|
+
content: row.content,
|
|
200
|
+
createdAt: row.created_at,
|
|
201
|
+
revoked: row.revoked_at !== null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Delete the share copy; the canonical copy stays with the user. */
|
|
206
|
+
async revoke(artifactId: string): Promise<boolean> {
|
|
207
|
+
await this.setup();
|
|
208
|
+
const row = await this.row(artifactId);
|
|
209
|
+
if (!row) return false;
|
|
210
|
+
await this.config.bucket.delete(artifactObjectKey(artifactId));
|
|
211
|
+
if (!row.revoked_at)
|
|
212
|
+
await this.db.run(
|
|
213
|
+
'UPDATE artifacts SET revoked_at = ? WHERE artifact_id = ?',
|
|
214
|
+
[new Date(this.now()).toISOString(), artifactId],
|
|
215
|
+
);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A deleted session takes its artefacts with it: the share copies first,
|
|
221
|
+
* so a failed delete keeps the rows (and the links still expire on time).
|
|
222
|
+
*/
|
|
223
|
+
async deleteForSession(sessionId: string): Promise<number> {
|
|
224
|
+
await this.setup();
|
|
225
|
+
const rows = await this.db.exec<{ artifact_id: string }>(
|
|
226
|
+
'SELECT artifact_id FROM artifacts WHERE session_id = ?',
|
|
227
|
+
[sessionId],
|
|
228
|
+
);
|
|
229
|
+
if (rows.length === 0) return 0;
|
|
230
|
+
await this.config.bucket.delete(
|
|
231
|
+
rows.map((row) => artifactObjectKey(row.artifact_id)),
|
|
232
|
+
);
|
|
233
|
+
await this.db.run('DELETE FROM artifacts WHERE session_id = ?', [
|
|
234
|
+
sessionId,
|
|
235
|
+
]);
|
|
236
|
+
return rows.length;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes expired artefact share copies from R2 on the cron tick.
|
|
3
|
+
*
|
|
4
|
+
* The data route deletes an expired copy only when someone opens it, so a
|
|
5
|
+
* copy nobody opens again would stay in the bucket for good. This sweep lists
|
|
6
|
+
* `art/` with each object's custom metadata (no per-object read) and deletes
|
|
7
|
+
* the ones whose `expiresAt` has passed.
|
|
8
|
+
*
|
|
9
|
+
* Cost per tick is kept to one state read while the last full sweep is
|
|
10
|
+
* recent. A sweep lists at most `maxPages` pages per tick; a larger bucket is
|
|
11
|
+
* finished over the next ticks from the stored cursor. The state object lives
|
|
12
|
+
* outside `art/`, so an operator's lifecycle rule on that prefix never removes
|
|
13
|
+
* it.
|
|
14
|
+
*
|
|
15
|
+
* Two overlapping runs need no lock: the second one only re-deletes keys the
|
|
16
|
+
* first already deleted (a no-op) and writes the same kind of state.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import type { Logger } from '../plugin-api/types';
|
|
20
|
+
import { ARTIFACT_OBJECT_PREFIX } from './store';
|
|
21
|
+
|
|
22
|
+
export const ARTIFACT_SWEEP_STATE_KEY = 'artifact-sweep/state';
|
|
23
|
+
|
|
24
|
+
/** R2's own ceiling for one `list` page and for one multi-key `delete`. */
|
|
25
|
+
const R2_MAX_BATCH = 1000;
|
|
26
|
+
const DEFAULT_SWEEP_MAX_PAGES = 20;
|
|
27
|
+
|
|
28
|
+
const sweepStateSchema = z.object({
|
|
29
|
+
v: z.literal(1),
|
|
30
|
+
sweptAt: z.string().nullable(),
|
|
31
|
+
cursor: z.string().nullable(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type ArtifactSweepState = z.infer<typeof sweepStateSchema>;
|
|
35
|
+
|
|
36
|
+
const NEVER_SWEPT: ArtifactSweepState = { v: 1, sweptAt: null, cursor: null };
|
|
37
|
+
|
|
38
|
+
export interface SweepStats {
|
|
39
|
+
pages: number;
|
|
40
|
+
seen: number;
|
|
41
|
+
deleted: number;
|
|
42
|
+
/** Objects without a readable `expiresAt`: left alone. */
|
|
43
|
+
unknown: number;
|
|
44
|
+
/** The call continued a sweep an earlier tick left unfinished. */
|
|
45
|
+
resumed: boolean;
|
|
46
|
+
elapsedMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SweepOutcome =
|
|
50
|
+
| { skipped: true }
|
|
51
|
+
| ({ skipped: false; done: boolean } & SweepStats)
|
|
52
|
+
| ({ skipped: false; error: string } & SweepStats);
|
|
53
|
+
|
|
54
|
+
export interface SweepOptions {
|
|
55
|
+
bucket: R2Bucket;
|
|
56
|
+
/** Clock for the expiry comparison and `sweptAt` (default `Date.now()`). */
|
|
57
|
+
now?: number;
|
|
58
|
+
/** A full sweep starts at most this often; an unfinished one resumes on every call. */
|
|
59
|
+
intervalMs: number;
|
|
60
|
+
/** Objects per `list` call (default and maximum 1000). */
|
|
61
|
+
pageSize?: number;
|
|
62
|
+
/** `list` calls per invocation (default 20). */
|
|
63
|
+
maxPages?: number;
|
|
64
|
+
/** Keys per `delete` call (default and maximum 1000). */
|
|
65
|
+
deleteBatchSize?: number;
|
|
66
|
+
log: Pick<Logger, 'log' | 'error'>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clampBatch(value: number | undefined, fallback: number): number {
|
|
70
|
+
if (value === undefined || !Number.isInteger(value) || value < 1)
|
|
71
|
+
return fallback;
|
|
72
|
+
return Math.min(value, R2_MAX_BATCH);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A missing, unreadable or unknown-version state counts as "never swept". */
|
|
76
|
+
async function readState(bucket: R2Bucket): Promise<ArtifactSweepState> {
|
|
77
|
+
const object = await bucket.get(ARTIFACT_SWEEP_STATE_KEY);
|
|
78
|
+
if (!object) return NEVER_SWEPT;
|
|
79
|
+
const text = await object.text();
|
|
80
|
+
let raw: unknown;
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
return NEVER_SWEPT;
|
|
85
|
+
}
|
|
86
|
+
const parsed = sweepStateSchema.safeParse(raw);
|
|
87
|
+
return parsed.success ? parsed.data : NEVER_SWEPT;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isExpired(
|
|
91
|
+
expiresAt: string | undefined,
|
|
92
|
+
now: number,
|
|
93
|
+
): boolean | undefined {
|
|
94
|
+
const at = Date.parse(expiresAt ?? '');
|
|
95
|
+
return Number.isFinite(at) ? at <= now : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Never throws: an R2 failure is logged once and returned as `error`. */
|
|
99
|
+
export async function sweepExpiredArtifacts(
|
|
100
|
+
opts: SweepOptions,
|
|
101
|
+
): Promise<SweepOutcome> {
|
|
102
|
+
const started = Date.now();
|
|
103
|
+
const stats: SweepStats = {
|
|
104
|
+
pages: 0,
|
|
105
|
+
seen: 0,
|
|
106
|
+
deleted: 0,
|
|
107
|
+
unknown: 0,
|
|
108
|
+
resumed: false,
|
|
109
|
+
elapsedMs: 0,
|
|
110
|
+
};
|
|
111
|
+
const summary = (): string =>
|
|
112
|
+
`pages=${stats.pages} seen=${stats.seen} deleted=${stats.deleted} unknown=${stats.unknown} resumed=${stats.resumed} elapsedMs=${stats.elapsedMs}`;
|
|
113
|
+
try {
|
|
114
|
+
const { bucket } = opts;
|
|
115
|
+
const now = opts.now ?? Date.now();
|
|
116
|
+
const pageSize = clampBatch(opts.pageSize, R2_MAX_BATCH);
|
|
117
|
+
const deleteBatchSize = clampBatch(opts.deleteBatchSize, R2_MAX_BATCH);
|
|
118
|
+
const maxPages =
|
|
119
|
+
opts.maxPages !== undefined &&
|
|
120
|
+
Number.isInteger(opts.maxPages) &&
|
|
121
|
+
opts.maxPages >= 1
|
|
122
|
+
? opts.maxPages
|
|
123
|
+
: DEFAULT_SWEEP_MAX_PAGES;
|
|
124
|
+
|
|
125
|
+
const state = await readState(bucket);
|
|
126
|
+
if (state.cursor === null && state.sweptAt !== null) {
|
|
127
|
+
const sweptAt = Date.parse(state.sweptAt);
|
|
128
|
+
if (Number.isFinite(sweptAt) && now - sweptAt < opts.intervalMs)
|
|
129
|
+
return { skipped: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
stats.resumed = state.cursor !== null;
|
|
133
|
+
let cursor: string | undefined = state.cursor ?? undefined;
|
|
134
|
+
let finished = false;
|
|
135
|
+
while (stats.pages < maxPages) {
|
|
136
|
+
const page = await bucket.list({
|
|
137
|
+
prefix: ARTIFACT_OBJECT_PREFIX,
|
|
138
|
+
limit: pageSize,
|
|
139
|
+
include: ['customMetadata'],
|
|
140
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
141
|
+
});
|
|
142
|
+
stats.pages++;
|
|
143
|
+
stats.seen += page.objects.length;
|
|
144
|
+
const expired: string[] = [];
|
|
145
|
+
for (const object of page.objects) {
|
|
146
|
+
const verdict = isExpired(object.customMetadata?.expiresAt, now);
|
|
147
|
+
if (verdict === undefined) stats.unknown++;
|
|
148
|
+
else if (verdict) expired.push(object.key);
|
|
149
|
+
}
|
|
150
|
+
for (let i = 0; i < expired.length; i += deleteBatchSize) {
|
|
151
|
+
const batch = expired.slice(i, i + deleteBatchSize);
|
|
152
|
+
await bucket.delete(batch);
|
|
153
|
+
stats.deleted += batch.length;
|
|
154
|
+
}
|
|
155
|
+
// `truncated`, never the page length: with `include` set R2 may return
|
|
156
|
+
// fewer objects than `limit` while more remain.
|
|
157
|
+
if (!page.truncated) {
|
|
158
|
+
finished = true;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
cursor = page.cursor;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const next: ArtifactSweepState = finished
|
|
165
|
+
? { v: 1, sweptAt: new Date(now).toISOString(), cursor: null }
|
|
166
|
+
: { v: 1, sweptAt: state.sweptAt, cursor: cursor ?? null };
|
|
167
|
+
await bucket.put(ARTIFACT_SWEEP_STATE_KEY, JSON.stringify(next), {
|
|
168
|
+
httpMetadata: { contentType: 'application/json' },
|
|
169
|
+
});
|
|
170
|
+
stats.elapsedMs = Date.now() - started;
|
|
171
|
+
opts.log.log(
|
|
172
|
+
`[artifacts] sweep ${finished ? 'finished' : 'paused, resumes next tick'}: ${summary()}`,
|
|
173
|
+
);
|
|
174
|
+
return { skipped: false, done: finished, ...stats };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
stats.elapsedMs = Date.now() - started;
|
|
177
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
178
|
+
opts.log.error(`[artifacts] sweep failed: ${message} (${summary()})`);
|
|
179
|
+
// A stored cursor R2 no longer accepts would fail every tick at its
|
|
180
|
+
// first list. Dropping it costs one write now and a sweep from the start
|
|
181
|
+
// next tick; a transient failure at that point pays the same price.
|
|
182
|
+
if (stats.resumed && stats.pages === 0)
|
|
183
|
+
await dropCursor(opts.bucket, opts.log).catch(() => undefined);
|
|
184
|
+
return { skipped: false, error: message, ...stats };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function dropCursor(
|
|
189
|
+
bucket: R2Bucket,
|
|
190
|
+
log: Pick<Logger, 'log'>,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const state = await readState(bucket);
|
|
193
|
+
if (state.cursor === null) return;
|
|
194
|
+
await bucket.put(
|
|
195
|
+
ARTIFACT_SWEEP_STATE_KEY,
|
|
196
|
+
JSON.stringify({ ...state, cursor: null }),
|
|
197
|
+
{ httpMetadata: { contentType: 'application/json' } },
|
|
198
|
+
);
|
|
199
|
+
log.log('[artifacts] sweep dropped its stored cursor; next tick starts over');
|
|
200
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { tool } from '../plugin-api/tool-helper';
|
|
3
|
+
import type { PluginTool } from '../plugin-api/types';
|
|
4
|
+
import type { ArtifactRef, ReplyContent } from '../delivery/types';
|
|
5
|
+
|
|
6
|
+
export const CREATE_ARTIFACT_TOOL = 'create_artifact';
|
|
7
|
+
|
|
8
|
+
export const CreateArtifactArgs = z.object({
|
|
9
|
+
title: z
|
|
10
|
+
.string()
|
|
11
|
+
.trim()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(120)
|
|
14
|
+
.describe('A short title for the document, shown on the link card.'),
|
|
15
|
+
content: z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(200_000)
|
|
19
|
+
.describe('The full document in Markdown.'),
|
|
20
|
+
message: z
|
|
21
|
+
.string()
|
|
22
|
+
.trim()
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(600)
|
|
25
|
+
.describe(
|
|
26
|
+
'One or two short sentences sent as the chat message before the link: the answer or the gist, not "here is the document".',
|
|
27
|
+
),
|
|
28
|
+
followUp: z
|
|
29
|
+
.string()
|
|
30
|
+
.trim()
|
|
31
|
+
.max(300)
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('At most one short question, sent after the link.'),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** What the tool returns; the Reply Plan reads it back off the ToolMessage. */
|
|
37
|
+
export const CreateArtifactResult = z.object({
|
|
38
|
+
ok: z.literal(true),
|
|
39
|
+
artifactId: z.string(),
|
|
40
|
+
title: z.string(),
|
|
41
|
+
url: z.string(),
|
|
42
|
+
mime: z.literal('text/markdown'),
|
|
43
|
+
bytes: z.number(),
|
|
44
|
+
expiresAt: z.string(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function resultRef(raw: string | undefined): ArtifactRef | null {
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = CreateArtifactResult.safeParse(JSON.parse(raw));
|
|
51
|
+
if (!parsed.success) return null;
|
|
52
|
+
const { artifactId, title, url, mime, bytes, expiresAt } = parsed.data;
|
|
53
|
+
return { artifactId, title, url, mime, bytes, expiresAt };
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What one call delivered, from its arguments and its (JSON) result. */
|
|
60
|
+
export function createArtifactParts(
|
|
61
|
+
args: unknown,
|
|
62
|
+
result: string | undefined,
|
|
63
|
+
): ReplyContent[] {
|
|
64
|
+
const parsed = CreateArtifactArgs.safeParse(args);
|
|
65
|
+
const ref = resultRef(result);
|
|
66
|
+
const parts: ReplyContent[] = [];
|
|
67
|
+
if (parsed.success) parts.push({ kind: 'text', text: parsed.data.message });
|
|
68
|
+
if (ref) parts.push({ kind: 'artifact', artifact: ref });
|
|
69
|
+
if (parsed.success && parsed.data.followUp)
|
|
70
|
+
parts.push({ kind: 'text', text: parsed.data.followUp });
|
|
71
|
+
return parts;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bound on chat surfaces only. Idempotent per tool call id, so a run resumed
|
|
76
|
+
* after a reset may run it again (`effect: 'read'`) and gets the same link.
|
|
77
|
+
*/
|
|
78
|
+
export function buildCreateArtifactTool(
|
|
79
|
+
create: (input: {
|
|
80
|
+
source: string;
|
|
81
|
+
title: string;
|
|
82
|
+
content: string;
|
|
83
|
+
}) => Promise<ArtifactRef>,
|
|
84
|
+
): PluginTool {
|
|
85
|
+
return tool(
|
|
86
|
+
async (rawArgs, ctx) => {
|
|
87
|
+
const args = CreateArtifactArgs.parse(rawArgs);
|
|
88
|
+
const ref = await create({
|
|
89
|
+
source: ctx.toolCallId ?? crypto.randomUUID(),
|
|
90
|
+
title: args.title,
|
|
91
|
+
content: args.content,
|
|
92
|
+
});
|
|
93
|
+
return { ok: true, ...ref };
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: CREATE_ARTIFACT_TOOL,
|
|
97
|
+
description:
|
|
98
|
+
'Send the user a document they open in their browser, together with a short chat message. Use it for anything too long or too structured for chat: a plan, report, comparison, table, draft, list of more than a few items, or code. Put the full Markdown in `content`, the answer or gist in `message`, and at most one question in `followUp`. Call it last and on its own: the reply ends after it, and the user receives the message, the link, then the question.',
|
|
99
|
+
schema: CreateArtifactArgs,
|
|
100
|
+
effect: 'read',
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|