@sema-agent/server 1.288.0 → 1.289.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/dist/config.d.ts +3 -0
- package/dist/config.js +5 -0
- package/dist/http/server.d.ts +3 -0
- package/dist/http/server.js +91 -1
- package/dist/main.js +81 -1
- package/dist/plugins/local-task-attachment-store.d.ts +20 -0
- package/dist/plugins/local-task-attachment-store.js +115 -0
- package/dist/plugins/task-attachment-store.d.ts +98 -0
- package/dist/plugins/task-attachment-store.js +224 -0
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -179,6 +179,9 @@ export interface ServiceConfig {
|
|
|
179
179
|
metricsToken?: string;
|
|
180
180
|
traceToken?: string;
|
|
181
181
|
corsOrigins: string[];
|
|
182
|
+
attachmentMaxBytes: number;
|
|
183
|
+
attachmentMimeAllowlist?: string[];
|
|
184
|
+
attachmentUnboundTtlMs: number;
|
|
182
185
|
principalHeader: string;
|
|
183
186
|
requirePrincipal: boolean;
|
|
184
187
|
autonomy?: Autonomy;
|
package/dist/config.js
CHANGED
|
@@ -544,6 +544,11 @@ export function loadConfig() {
|
|
|
544
544
|
metricsToken: process.env.METRICS_TOKEN || undefined,
|
|
545
545
|
traceToken: process.env.TRACE_TOKEN || undefined,
|
|
546
546
|
corsOrigins: (process.env.CORS_ORIGIN ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
547
|
+
attachmentMaxBytes: Math.max(1024, numEnv("ATTACHMENT_MAX_BYTES", String(32 * 1024 * 1024))),
|
|
548
|
+
...(process.env.ATTACHMENT_MIME_ALLOWLIST
|
|
549
|
+
? { attachmentMimeAllowlist: process.env.ATTACHMENT_MIME_ALLOWLIST.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean) }
|
|
550
|
+
: {}),
|
|
551
|
+
attachmentUnboundTtlMs: Math.max(60_000, numEnv("ATTACHMENT_UNBOUND_TTL_MS", String(24 * 3600 * 1000))),
|
|
547
552
|
principalHeader: headerNameEnv("PRINCIPAL_HEADER", "x-agent-principal"),
|
|
548
553
|
requirePrincipal: process.env.REQUIRE_PRINCIPAL === "true",
|
|
549
554
|
autonomy: parseAutonomy(process.env.AUTONOMY),
|
package/dist/http/server.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { type OwnerAwareSessionStore } from "../security.js";
|
|
|
7
7
|
import type { BakeState, BakeErrorCode } from "../plugins/tidb-image-bake.js";
|
|
8
8
|
import type { RunStore, ApprovalStore, ImageIndex, ImageBake, CheckpointStoreFull, ResumeAnchorStore, ApprovalExemptionStore, ServiceSessionPolicyStore, ServiceFileSnapshotStore, StoreBackend } from "../plugins/store-backend.js";
|
|
9
9
|
import { type MemorySyncRequest, type MemorySyncResponse } from "../memory-sync.js";
|
|
10
|
+
import type { TaskAttachmentStore } from "../plugins/task-attachment-store.js";
|
|
10
11
|
import type { LeaderEndpoint } from "../leader/endpoint.js";
|
|
11
12
|
import { type WorkflowAgentRegistry } from "../orchestration/workflow-agent-steer.js";
|
|
12
13
|
import type { SubagentSteerRegistry } from "../orchestration/subagent-steer.js";
|
|
@@ -70,6 +71,7 @@ export interface ServiceDeps {
|
|
|
70
71
|
parkedReviveTool?: import("@sema-agent/core").ToolSpec;
|
|
71
72
|
parkedKnownAgentTypes?: ReadonlySet<string>;
|
|
72
73
|
parkedReviveInheritedGate?: (row: import("@sema-agent/core").BackgroundAgentRecord) => unknown;
|
|
74
|
+
taskAttachmentStore?: TaskAttachmentStore;
|
|
73
75
|
imageIndex?: ImageIndex;
|
|
74
76
|
imageBakes?: ImageBake;
|
|
75
77
|
leaderEndpoint?: LeaderEndpoint;
|
|
@@ -165,6 +167,7 @@ export interface TaskRequestBody {
|
|
|
165
167
|
} | {
|
|
166
168
|
url: string;
|
|
167
169
|
}>;
|
|
170
|
+
attachmentIds?: string[];
|
|
168
171
|
clientContext?: {
|
|
169
172
|
timeZone?: string;
|
|
170
173
|
userEmail?: string;
|
package/dist/http/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { once } from "node:events";
|
|
3
3
|
import { timingSafeEqual, createHash, randomBytes } from "node:crypto";
|
|
4
|
-
import { sessionLogDigest, sessionLogDigestsComparable, uuidv7, callKeyOrdinal, isThinkingLevel, DEFAULT_EFFORT_LEVELS, expandTiers, materializeMcpTools, runWithVerification, resumeWithVerification, runCascade, CheckpointError, mintCheckpointToken, SessionPolicyError, SessionError, HAND_TOOL_EFFECTS, canonicalToolName, formatUserScope, defaultTaskRegistry, validatePendingSteer, StreamingImportValidator, getWorkflowRun, subscribeWorkflow, deriveAgentDisplayStatus } from "@sema-agent/core";
|
|
4
|
+
import { sessionLogDigest, sessionLogDigestsComparable, uuidv7, sanitizePathComponent, callKeyOrdinal, isThinkingLevel, DEFAULT_EFFORT_LEVELS, expandTiers, materializeMcpTools, runWithVerification, resumeWithVerification, runCascade, CheckpointError, mintCheckpointToken, SessionPolicyError, SessionError, HAND_TOOL_EFFECTS, canonicalToolName, formatUserScope, defaultTaskRegistry, validatePendingSteer, StreamingImportValidator, getWorkflowRun, subscribeWorkflow, deriveAgentDisplayStatus } from "@sema-agent/core";
|
|
5
5
|
import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
|
|
6
6
|
import { matchCatalogModel } from "../model-select.js";
|
|
7
7
|
import { mcpForScenario } from "../sema-registry.js";
|
|
@@ -4347,6 +4347,96 @@ export function createHttpServer(deps) {
|
|
|
4347
4347
|
return;
|
|
4348
4348
|
}
|
|
4349
4349
|
}
|
|
4350
|
+
{
|
|
4351
|
+
const am = /^\/v1\/attachments(?:\/([^/?]+))?$/.exec(url);
|
|
4352
|
+
if (am) {
|
|
4353
|
+
if (rateLimited(req, res) || quotaExceeded(req, res))
|
|
4354
|
+
return;
|
|
4355
|
+
const store = deps.taskAttachmentStore;
|
|
4356
|
+
if (!store) {
|
|
4357
|
+
sendJson(res, 501, { error: "attachment store not configured" });
|
|
4358
|
+
return;
|
|
4359
|
+
}
|
|
4360
|
+
const principal = gatedPrincipal(req, deps.config);
|
|
4361
|
+
if (deps.config.requirePrincipal && principal === undefined) {
|
|
4362
|
+
sendJson(res, 401, { error: `missing principal header '${deps.config.principalHeader}'` });
|
|
4363
|
+
return;
|
|
4364
|
+
}
|
|
4365
|
+
const owner = principal ?? "default";
|
|
4366
|
+
const attId = am[1] !== undefined ? safeDecode(am[1]) : undefined;
|
|
4367
|
+
if (attId === undefined && req.method === "POST") {
|
|
4368
|
+
const q = new URL(req.url ?? "", "http://x").searchParams;
|
|
4369
|
+
const rawName = q.get("name");
|
|
4370
|
+
const base = ((rawName ?? "").split(/[/\\]/).filter(Boolean).pop() ?? "").slice(0, 128);
|
|
4371
|
+
if (!rawName || base === "" || base === "." || base === ".." || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(base)) {
|
|
4372
|
+
sendJson(res, 400, { error: "query param `name` is required and must reduce to a safe basename ([A-Za-z0-9._-], not starting with a dot)" });
|
|
4373
|
+
return;
|
|
4374
|
+
}
|
|
4375
|
+
const name = sanitizePathComponent(base);
|
|
4376
|
+
const mime = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
4377
|
+
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mime) || mime.length > 128) {
|
|
4378
|
+
sendJson(res, 400, { error: "content-type must be a well-formed mime type (type/subtype)" });
|
|
4379
|
+
return;
|
|
4380
|
+
}
|
|
4381
|
+
const allow = deps.config.attachmentMimeAllowlist;
|
|
4382
|
+
if (allow && !allow.some((a) => a === mime || (a.endsWith("/*") && mime.startsWith(a.slice(0, -1))))) {
|
|
4383
|
+
sendJson(res, 415, { code: "attachment.mime_not_allowed", error: `mime '${mime}' is not in this deployment's allowlist`, allowed: allow });
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
const max = deps.config.attachmentMaxBytes ?? 32 * 1024 * 1024;
|
|
4387
|
+
let body;
|
|
4388
|
+
try {
|
|
4389
|
+
body = await readRawBody(req, max);
|
|
4390
|
+
}
|
|
4391
|
+
catch (err) {
|
|
4392
|
+
if (err instanceof HttpError && err.status === 413) {
|
|
4393
|
+
sendJson(res, 413, { code: "attachment.too_large", error: `attachment exceeds the per-file limit (${max} bytes)`, maxBytes: max });
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4396
|
+
throw err;
|
|
4397
|
+
}
|
|
4398
|
+
const rec = {
|
|
4399
|
+
id: uuidv7(),
|
|
4400
|
+
owner,
|
|
4401
|
+
name,
|
|
4402
|
+
mime,
|
|
4403
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
4404
|
+
sizeBytes: body.length,
|
|
4405
|
+
createdAtMs: Date.now(),
|
|
4406
|
+
};
|
|
4407
|
+
await store.put({ ...rec, content: new Uint8Array(body) });
|
|
4408
|
+
deps.metrics?.inc("attachment_uploads_total");
|
|
4409
|
+
sendJson(res, 201, { id: rec.id, name: rec.name, mime: rec.mime, sha256: rec.sha256, sizeBytes: rec.sizeBytes });
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4412
|
+
if (attId !== undefined && attId !== null && req.method === "GET") {
|
|
4413
|
+
const meta = await store.get(owner, attId);
|
|
4414
|
+
const bytes = meta ? await store.getContent(owner, attId) : null;
|
|
4415
|
+
if (!meta || !bytes) {
|
|
4416
|
+
sendJson(res, 404, { error: "not found" });
|
|
4417
|
+
return;
|
|
4418
|
+
}
|
|
4419
|
+
res.writeHead(200, {
|
|
4420
|
+
"content-type": meta.mime,
|
|
4421
|
+
"content-length": String(bytes.byteLength),
|
|
4422
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
|
|
4423
|
+
});
|
|
4424
|
+
res.end(Buffer.from(bytes));
|
|
4425
|
+
return;
|
|
4426
|
+
}
|
|
4427
|
+
if (attId !== undefined && attId !== null && req.method === "DELETE") {
|
|
4428
|
+
const ok = await store.delete(owner, attId);
|
|
4429
|
+
if (!ok) {
|
|
4430
|
+
sendJson(res, 404, { error: "not found" });
|
|
4431
|
+
return;
|
|
4432
|
+
}
|
|
4433
|
+
res.writeHead(204).end();
|
|
4434
|
+
return;
|
|
4435
|
+
}
|
|
4436
|
+
sendJson(res, 404, { error: "not found" });
|
|
4437
|
+
return;
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4350
4440
|
if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/notify$/.test(url)) {
|
|
4351
4441
|
if (rateLimited(req, res) || quotaExceeded(req, res))
|
|
4352
4442
|
return;
|
package/dist/main.js
CHANGED
|
@@ -78,6 +78,9 @@ import { createConfigProvider, raceBootFetch, BOOT_FETCH_DEFERRED } from "./conf
|
|
|
78
78
|
import { validatePromptsDomain, centerPromptProvider, centerIdentityAssembled, applyCatalogToSource, CORE_ENGINE_VERSION, withPromptArtifactBackfill } from "./capabilities/center-prompts.js";
|
|
79
79
|
import { defaultLkgPath, defaultSkillCacheDir, saveLkg, loadLkg } from "./config-lkg.js";
|
|
80
80
|
import { TiDBRosterStore, PgRosterStore, ensureTiDBRosterSchema, ensurePgRosterSchema } from "./plugins/roster-store-sql.js";
|
|
81
|
+
import { TiDBTaskAttachmentStore, PgTaskAttachmentStore, ensureTiDBTaskAttachmentSchema, ensurePgTaskAttachmentSchema, bindAttachmentsForTask, materializeAttachmentsInto } from "./plugins/task-attachment-store.js";
|
|
82
|
+
import { LocalTaskAttachmentStore } from "./plugins/local-task-attachment-store.js";
|
|
83
|
+
import { MinioBlobBackend } from "./plugins/blob-backend.js";
|
|
81
84
|
import { TiDBBackgroundAgentStore, PgBackgroundAgentStore, ensureTiDBBackgroundAgentSchema, ensurePgBackgroundAgentSchema } from "./plugins/background-agent-store-sql.js";
|
|
82
85
|
import { TiDBMailboxStore, PgMailboxStore, ensureTiDBMailboxSchema, ensurePgMailboxSchema } from "./plugins/mailbox-store-sql.js";
|
|
83
86
|
import { createPrincipalCapsClient, gateExecutionLane, scopedTokenNeedsWorker, applyObserverEnvOptIn } from "./runtime-caps-resolver.js";
|
|
@@ -535,6 +538,36 @@ async function main() {
|
|
|
535
538
|
if (backgroundAgentStore)
|
|
536
539
|
logger.info("background_agent_store_enabled", { backend: pgPool ? "pg" : mysqlPool ? "tidb" : "file" });
|
|
537
540
|
}
|
|
541
|
+
let taskAttachmentStore;
|
|
542
|
+
{
|
|
543
|
+
const mysqlPool = backend?.mysqlPool?.();
|
|
544
|
+
const pgPool = backend?.pgPool?.();
|
|
545
|
+
if (pgPool || mysqlPool) {
|
|
546
|
+
const minio = config.snapshotBlobStore;
|
|
547
|
+
if (!minio) {
|
|
548
|
+
logger.error("attachments_disabled_object_store_required", {
|
|
549
|
+
hint: "cloud deployments must configure object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) — attachment routes will 501 until it is set",
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const bytes = new MinioBlobBackend({ ...minio, keyPrefix: `${minio.keyPrefix ?? ""}attachments/` });
|
|
554
|
+
if (pgPool) {
|
|
555
|
+
const q = async (text, params) => { const r = await pgPool.query(text, params); return { rows: r.rows }; };
|
|
556
|
+
await ensurePgTaskAttachmentSchema(q);
|
|
557
|
+
taskAttachmentStore = new PgTaskAttachmentStore(q, bytes);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
await ensureTiDBTaskAttachmentSchema(mysqlPool);
|
|
561
|
+
taskAttachmentStore = new TiDBTaskAttachmentStore(mysqlPool, bytes);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else if (backend?.kind === "local") {
|
|
566
|
+
taskAttachmentStore = new LocalTaskAttachmentStore(config.localDataRoot ?? localRoot);
|
|
567
|
+
}
|
|
568
|
+
if (taskAttachmentStore)
|
|
569
|
+
logger.info("task_attachment_store_enabled", { backend: pgPool ? "pg+minio" : mysqlPool ? "tidb+minio" : "file", maxBytes: config.attachmentMaxBytes });
|
|
570
|
+
}
|
|
538
571
|
let mailboxStore;
|
|
539
572
|
{
|
|
540
573
|
const mysqlPool = backend?.mysqlPool?.();
|
|
@@ -804,6 +837,17 @@ async function main() {
|
|
|
804
837
|
executionEnvFactory = registry.wrapFactory(executionEnvFactory);
|
|
805
838
|
return registry;
|
|
806
839
|
})();
|
|
840
|
+
if (taskAttachmentStore && executionEnvFactory) {
|
|
841
|
+
const inner = executionEnvFactory;
|
|
842
|
+
const attStore = taskAttachmentStore;
|
|
843
|
+
executionEnvFactory = async (ctx) => {
|
|
844
|
+
const env = await inner(ctx);
|
|
845
|
+
const n = await materializeAttachmentsInto(env, attStore, ctx.sessionId);
|
|
846
|
+
if (n > 0)
|
|
847
|
+
logger.info("attachments_materialized", { sessionId: ctx.sessionId, count: n });
|
|
848
|
+
return env;
|
|
849
|
+
};
|
|
850
|
+
}
|
|
807
851
|
if (executionEnvFactory) {
|
|
808
852
|
const isolated = config.remoteExec.provider === "e2b" ||
|
|
809
853
|
config.remoteExec.provider === "k8s" ||
|
|
@@ -1381,6 +1425,7 @@ async function main() {
|
|
|
1381
1425
|
costQuota.reap();
|
|
1382
1426
|
void toolResultStore?.reapOlderThan?.(Date.now() - config.toolResultTtlSec * 1000)?.catch(() => undefined);
|
|
1383
1427
|
void fileSnapshotStore?.sweepOrphanBlobs?.().catch(() => undefined);
|
|
1428
|
+
void taskAttachmentStore?.reapUnbound(Date.now() - config.attachmentUnboundTtlMs).then(reapCount("attachments_reaped_total", {})).catch(() => undefined);
|
|
1384
1429
|
void backend?.session()?.sweepStagingSessions?.().catch(() => undefined);
|
|
1385
1430
|
void imageBakes?.reapStaleBakes(config.imageBakes.staleMs).then(reapCount("bakes_reaped_total", {})).catch(() => undefined);
|
|
1386
1431
|
void worktreeReap?.();
|
|
@@ -1671,6 +1716,8 @@ async function main() {
|
|
|
1671
1716
|
await approvalExemptionStore.deleteBySession(sessionId);
|
|
1672
1717
|
if (sessionPolicyStore?.deleteBySession)
|
|
1673
1718
|
await sessionPolicyStore.deleteBySession(sessionId);
|
|
1719
|
+
if (taskAttachmentStore)
|
|
1720
|
+
await taskAttachmentStore.deleteBySession(sessionId);
|
|
1674
1721
|
if (fileSnapshotStore?.deleteBySession)
|
|
1675
1722
|
await fileSnapshotStore.deleteBySession(sessionId);
|
|
1676
1723
|
if (workflowCompletionInbox)
|
|
@@ -1757,6 +1804,7 @@ async function main() {
|
|
|
1757
1804
|
config,
|
|
1758
1805
|
authorize,
|
|
1759
1806
|
sessionStoreLabel: config.sessionBackend === "tidb" && backend ? `durable(${backend.kind})` : config.sessionBackend,
|
|
1807
|
+
...(taskAttachmentStore ? { taskAttachmentStore } : {}),
|
|
1760
1808
|
modelReady: () => modelReadyState.ready,
|
|
1761
1809
|
scenarioDetails,
|
|
1762
1810
|
...(registryJwtVerifier ? { registryJwtVerifier } : {}),
|
|
@@ -1850,6 +1898,38 @@ async function main() {
|
|
|
1850
1898
|
if (parsedSettings.deferred.length > 0) {
|
|
1851
1899
|
logger.warn("task_settings_deferred", { fields: parsedSettings.deferred, sessionId: auth?.sessionId ?? null });
|
|
1852
1900
|
}
|
|
1901
|
+
let attachmentNotice;
|
|
1902
|
+
{
|
|
1903
|
+
const reqAtt = body.attachmentIds;
|
|
1904
|
+
if (reqAtt !== undefined) {
|
|
1905
|
+
if (!Array.isArray(reqAtt) || !reqAtt.every((x) => typeof x === "string" && x.length > 0 && x.length <= 64)) {
|
|
1906
|
+
throw new HttpError(400, "`attachmentIds` must be an array of attachment ids (strings)");
|
|
1907
|
+
}
|
|
1908
|
+
if (reqAtt.length > 16)
|
|
1909
|
+
throw new HttpError(400, "`attachmentIds` exceeds the per-task limit (16)");
|
|
1910
|
+
if (reqAtt.length > 0) {
|
|
1911
|
+
if (!taskAttachmentStore)
|
|
1912
|
+
throw new HttpError(501, "attachmentIds require a store backend (DB_BACKEND=tidb|pg|local)");
|
|
1913
|
+
if (!auth?.sessionId)
|
|
1914
|
+
throw new HttpError(400, "`attachmentIds` requires a session-resolving deployment (no authorizer session)");
|
|
1915
|
+
const sid = auth.sessionId;
|
|
1916
|
+
const r = await bindAttachmentsForTask({
|
|
1917
|
+
store: taskAttachmentStore,
|
|
1918
|
+
owner: auth?.principal ?? "default",
|
|
1919
|
+
ids: reqAtt,
|
|
1920
|
+
sessionId: sid,
|
|
1921
|
+
leg: opts?.leg === "fresh" ? "fresh" : "resume",
|
|
1922
|
+
nowMs: Date.now(),
|
|
1923
|
+
onMissing: (id) => {
|
|
1924
|
+
if (opts?.leg === "fresh")
|
|
1925
|
+
throw new HttpError(400, `attachment not found: ${id}`);
|
|
1926
|
+
logger.warn("attachment_missing_on_resume", { sessionId: sid, id });
|
|
1927
|
+
},
|
|
1928
|
+
});
|
|
1929
|
+
attachmentNotice = r.notice;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1853
1933
|
const execLane = config.remoteExec?.provider ?? "in-process";
|
|
1854
1934
|
if (typeof body.cwd === "string" && body.cwd.length > 0 && auth?.sessionId) {
|
|
1855
1935
|
if (cwdHonored(config) && isValidCwd(body.cwd))
|
|
@@ -1943,7 +2023,7 @@ async function main() {
|
|
|
1943
2023
|
const s4ProjectId = auth?.resolvedProjectId ?? (typeof body.projectId === "string" && body.projectId ? body.projectId : undefined);
|
|
1944
2024
|
const s4DefaultScopes = s4ProjectId ? config.projects[s4ProjectId]?.defaultScopes : undefined;
|
|
1945
2025
|
const spec = {
|
|
1946
|
-
objective: picked.cleanedObjective,
|
|
2026
|
+
objective: attachmentNotice ? `${picked.cleanedObjective}\n\n${attachmentNotice}` : picked.cleanedObjective,
|
|
1947
2027
|
systemPrompt: typeof body.systemPrompt === "string" ? body.systemPrompt : undefined,
|
|
1948
2028
|
appendSystemPrompt: acceptedAppend,
|
|
1949
2029
|
sessionId: auth?.sessionId,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TaskAttachmentStore, TaskAttachmentRecord } from "./task-attachment-store.js";
|
|
2
|
+
export declare class LocalTaskAttachmentStore implements TaskAttachmentStore {
|
|
3
|
+
private readonly dir;
|
|
4
|
+
private metas;
|
|
5
|
+
constructor(rootDir: string);
|
|
6
|
+
private hydrate;
|
|
7
|
+
put(rec: TaskAttachmentRecord & {
|
|
8
|
+
content: Uint8Array;
|
|
9
|
+
}): Promise<void>;
|
|
10
|
+
get(owner: string, id: string): Promise<TaskAttachmentRecord | null>;
|
|
11
|
+
getContent(owner: string, id: string): Promise<Uint8Array | null>;
|
|
12
|
+
private persistMeta;
|
|
13
|
+
bind(owner: string, ids: string[], sessionId: string, boundAtMs: number): Promise<number>;
|
|
14
|
+
listBySession(sessionId: string): Promise<TaskAttachmentRecord[]>;
|
|
15
|
+
private removeRow;
|
|
16
|
+
delete(owner: string, id: string): Promise<boolean>;
|
|
17
|
+
deleteBySession(sessionId: string): Promise<number>;
|
|
18
|
+
reapUnbound(olderThanMs: number): Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=local-task-attachment-store.d.ts.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { promises as fsp } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const ID_RE = /^[0-9a-f-]{16,64}$/i;
|
|
4
|
+
export class LocalTaskAttachmentStore {
|
|
5
|
+
dir;
|
|
6
|
+
metas;
|
|
7
|
+
constructor(rootDir) {
|
|
8
|
+
this.dir = join(rootDir, "attachments");
|
|
9
|
+
}
|
|
10
|
+
async hydrate() {
|
|
11
|
+
if (this.metas)
|
|
12
|
+
return this.metas;
|
|
13
|
+
const m = new Map();
|
|
14
|
+
try {
|
|
15
|
+
for (const f of await fsp.readdir(this.dir)) {
|
|
16
|
+
if (!f.endsWith(".json"))
|
|
17
|
+
continue;
|
|
18
|
+
const id = f.slice(0, -5);
|
|
19
|
+
if (!ID_RE.test(id))
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
const meta = JSON.parse(await fsp.readFile(join(this.dir, f), "utf8"));
|
|
23
|
+
if (meta && meta.id === id)
|
|
24
|
+
m.set(id, meta);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
}
|
|
32
|
+
this.metas = m;
|
|
33
|
+
return m;
|
|
34
|
+
}
|
|
35
|
+
async put(rec) {
|
|
36
|
+
const metas = await this.hydrate();
|
|
37
|
+
await fsp.mkdir(this.dir, { recursive: true });
|
|
38
|
+
const { content, ...meta } = rec;
|
|
39
|
+
const binTmp = join(this.dir, `${rec.id}.bin.tmp`);
|
|
40
|
+
const metaTmp = join(this.dir, `${rec.id}.json.tmp`);
|
|
41
|
+
await fsp.writeFile(binTmp, Buffer.from(content));
|
|
42
|
+
await fsp.rename(binTmp, join(this.dir, `${rec.id}.bin`));
|
|
43
|
+
await fsp.writeFile(metaTmp, JSON.stringify(meta));
|
|
44
|
+
await fsp.rename(metaTmp, join(this.dir, `${rec.id}.json`));
|
|
45
|
+
metas.set(rec.id, { ...meta });
|
|
46
|
+
}
|
|
47
|
+
async get(owner, id) {
|
|
48
|
+
const m = (await this.hydrate()).get(id);
|
|
49
|
+
return m && m.owner === owner ? { ...m } : null;
|
|
50
|
+
}
|
|
51
|
+
async getContent(owner, id) {
|
|
52
|
+
const m = (await this.hydrate()).get(id);
|
|
53
|
+
if (!m || m.owner !== owner)
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
return new Uint8Array(await fsp.readFile(join(this.dir, `${id}.bin`)));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async persistMeta(m) {
|
|
63
|
+
const tmp = join(this.dir, `${m.id}.json.tmp`);
|
|
64
|
+
await fsp.writeFile(tmp, JSON.stringify(m));
|
|
65
|
+
await fsp.rename(tmp, join(this.dir, `${m.id}.json`));
|
|
66
|
+
}
|
|
67
|
+
async bind(owner, ids, sessionId, boundAtMs) {
|
|
68
|
+
const metas = await this.hydrate();
|
|
69
|
+
let bound = 0;
|
|
70
|
+
for (const id of ids) {
|
|
71
|
+
const m = metas.get(id);
|
|
72
|
+
if (!m || m.owner !== owner)
|
|
73
|
+
continue;
|
|
74
|
+
m.sessionId = sessionId;
|
|
75
|
+
m.boundAtMs = boundAtMs;
|
|
76
|
+
await this.persistMeta(m);
|
|
77
|
+
bound++;
|
|
78
|
+
}
|
|
79
|
+
return bound;
|
|
80
|
+
}
|
|
81
|
+
async listBySession(sessionId) {
|
|
82
|
+
const metas = await this.hydrate();
|
|
83
|
+
return [...metas.values()]
|
|
84
|
+
.filter((m) => m.sessionId === sessionId)
|
|
85
|
+
.sort((a, b) => a.createdAtMs - b.createdAtMs || (a.id < b.id ? -1 : 1))
|
|
86
|
+
.map((m) => ({ ...m }));
|
|
87
|
+
}
|
|
88
|
+
async removeRow(id) {
|
|
89
|
+
await fsp.rm(join(this.dir, `${id}.json`), { force: true });
|
|
90
|
+
await fsp.rm(join(this.dir, `${id}.bin`), { force: true });
|
|
91
|
+
this.metas?.delete(id);
|
|
92
|
+
}
|
|
93
|
+
async delete(owner, id) {
|
|
94
|
+
const m = (await this.hydrate()).get(id);
|
|
95
|
+
if (!m || m.owner !== owner)
|
|
96
|
+
return false;
|
|
97
|
+
await this.removeRow(id);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
async deleteBySession(sessionId) {
|
|
101
|
+
const metas = await this.hydrate();
|
|
102
|
+
const hit = [...metas.values()].filter((m) => m.sessionId === sessionId);
|
|
103
|
+
for (const m of hit)
|
|
104
|
+
await this.removeRow(m.id);
|
|
105
|
+
return hit.length;
|
|
106
|
+
}
|
|
107
|
+
async reapUnbound(olderThanMs) {
|
|
108
|
+
const metas = await this.hydrate();
|
|
109
|
+
const hit = [...metas.values()].filter((m) => m.sessionId === undefined && m.createdAtMs < olderThanMs);
|
|
110
|
+
for (const m of hit)
|
|
111
|
+
await this.removeRow(m.id);
|
|
112
|
+
return hit.length;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=local-task-attachment-store.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Pool } from "mysql2/promise";
|
|
2
|
+
import type { PgQueryFn } from "./pg-query.js";
|
|
3
|
+
export interface TaskAttachmentRecord {
|
|
4
|
+
id: string;
|
|
5
|
+
owner: string;
|
|
6
|
+
name: string;
|
|
7
|
+
mime: string;
|
|
8
|
+
sha256: string;
|
|
9
|
+
sizeBytes: number;
|
|
10
|
+
createdAtMs: number;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
boundAtMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface TaskAttachmentStore {
|
|
15
|
+
put(rec: TaskAttachmentRecord & {
|
|
16
|
+
content: Uint8Array;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
get(owner: string, id: string): Promise<TaskAttachmentRecord | null>;
|
|
19
|
+
getContent(owner: string, id: string): Promise<Uint8Array | null>;
|
|
20
|
+
bind(owner: string, ids: string[], sessionId: string, boundAtMs: number): Promise<number>;
|
|
21
|
+
listBySession(sessionId: string): Promise<TaskAttachmentRecord[]>;
|
|
22
|
+
delete(owner: string, id: string): Promise<boolean>;
|
|
23
|
+
deleteBySession(sessionId: string): Promise<number>;
|
|
24
|
+
reapUnbound(olderThanMs: number): Promise<number>;
|
|
25
|
+
}
|
|
26
|
+
export declare const TASK_ATTACHMENT_TABLE = "task_attachment";
|
|
27
|
+
export interface AttachmentBytesStore {
|
|
28
|
+
putBlob(hash: string, bytes: Uint8Array): Promise<void>;
|
|
29
|
+
getBlob(hash: string): Promise<Uint8Array | undefined>;
|
|
30
|
+
deleteBlobs(hashes: string[]): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
export declare function materializedRelPaths(atts: TaskAttachmentRecord[]): Map<string, string>;
|
|
33
|
+
export declare function bindAttachmentsForTask(opts: {
|
|
34
|
+
store: TaskAttachmentStore;
|
|
35
|
+
owner: string;
|
|
36
|
+
ids: string[];
|
|
37
|
+
sessionId: string;
|
|
38
|
+
leg: "fresh" | "resume";
|
|
39
|
+
nowMs: number;
|
|
40
|
+
onMissing: (id: string) => void | never;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
notice: string | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function materializeAttachmentsInto(env: {
|
|
45
|
+
createDir(path: string, options?: {
|
|
46
|
+
recursive?: boolean;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
ok: boolean;
|
|
49
|
+
error?: {
|
|
50
|
+
message?: string;
|
|
51
|
+
};
|
|
52
|
+
} | {
|
|
53
|
+
ok: true;
|
|
54
|
+
}>;
|
|
55
|
+
writeFile(path: string, content: string | Uint8Array): Promise<{
|
|
56
|
+
ok: boolean;
|
|
57
|
+
error?: {
|
|
58
|
+
message?: string;
|
|
59
|
+
};
|
|
60
|
+
} | {
|
|
61
|
+
ok: true;
|
|
62
|
+
}>;
|
|
63
|
+
}, store: TaskAttachmentStore, sessionId: string): Promise<number>;
|
|
64
|
+
export declare function ensureTiDBTaskAttachmentSchema(pool: Pool): Promise<void>;
|
|
65
|
+
export declare function ensurePgTaskAttachmentSchema(q: PgQueryFn): Promise<void>;
|
|
66
|
+
export declare class TiDBTaskAttachmentStore implements TaskAttachmentStore {
|
|
67
|
+
private readonly pool;
|
|
68
|
+
private readonly bytes;
|
|
69
|
+
constructor(pool: Pool, bytes: AttachmentBytesStore);
|
|
70
|
+
put(rec: TaskAttachmentRecord & {
|
|
71
|
+
content: Uint8Array;
|
|
72
|
+
}): Promise<void>;
|
|
73
|
+
get(owner: string, id: string): Promise<TaskAttachmentRecord | null>;
|
|
74
|
+
getContent(owner: string, id: string): Promise<Uint8Array | null>;
|
|
75
|
+
private deleteBytesIfUnreferenced;
|
|
76
|
+
bind(owner: string, ids: string[], sessionId: string, boundAtMs: number): Promise<number>;
|
|
77
|
+
listBySession(sessionId: string): Promise<TaskAttachmentRecord[]>;
|
|
78
|
+
delete(owner: string, id: string): Promise<boolean>;
|
|
79
|
+
deleteBySession(sessionId: string): Promise<number>;
|
|
80
|
+
reapUnbound(olderThanMs: number): Promise<number>;
|
|
81
|
+
}
|
|
82
|
+
export declare class PgTaskAttachmentStore implements TaskAttachmentStore {
|
|
83
|
+
private readonly q;
|
|
84
|
+
private readonly bytes;
|
|
85
|
+
constructor(q: PgQueryFn, bytes: AttachmentBytesStore);
|
|
86
|
+
put(rec: TaskAttachmentRecord & {
|
|
87
|
+
content: Uint8Array;
|
|
88
|
+
}): Promise<void>;
|
|
89
|
+
get(owner: string, id: string): Promise<TaskAttachmentRecord | null>;
|
|
90
|
+
getContent(owner: string, id: string): Promise<Uint8Array | null>;
|
|
91
|
+
private deleteBytesIfUnreferenced;
|
|
92
|
+
bind(owner: string, ids: string[], sessionId: string, boundAtMs: number): Promise<number>;
|
|
93
|
+
listBySession(sessionId: string): Promise<TaskAttachmentRecord[]>;
|
|
94
|
+
delete(owner: string, id: string): Promise<boolean>;
|
|
95
|
+
deleteBySession(sessionId: string): Promise<number>;
|
|
96
|
+
reapUnbound(olderThanMs: number): Promise<number>;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=task-attachment-store.d.ts.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
export const TASK_ATTACHMENT_TABLE = "task_attachment";
|
|
2
|
+
export function materializedRelPaths(atts) {
|
|
3
|
+
const used = new Set();
|
|
4
|
+
const out = new Map();
|
|
5
|
+
for (const a of atts) {
|
|
6
|
+
const n = used.has(a.name) ? `${a.id.slice(0, 8)}-${a.name}` : a.name;
|
|
7
|
+
used.add(n);
|
|
8
|
+
out.set(a.id, `attachments/${n}`);
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
const COLS = "id, owner, name, mime, sha256, size_bytes, session_id, created_at_ms, bound_at_ms";
|
|
13
|
+
function rowToRecord(r) {
|
|
14
|
+
return {
|
|
15
|
+
id: String(r.id),
|
|
16
|
+
owner: String(r.owner),
|
|
17
|
+
name: String(r.name),
|
|
18
|
+
mime: String(r.mime),
|
|
19
|
+
sha256: String(r.sha256),
|
|
20
|
+
sizeBytes: Number(r.size_bytes),
|
|
21
|
+
createdAtMs: Number(r.created_at_ms),
|
|
22
|
+
...(r.session_id !== null && r.session_id !== undefined ? { sessionId: String(r.session_id) } : {}),
|
|
23
|
+
...(r.bound_at_ms !== null && r.bound_at_ms !== undefined ? { boundAtMs: Number(r.bound_at_ms) } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export async function bindAttachmentsForTask(opts) {
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const id of opts.ids) {
|
|
29
|
+
const rec = await opts.store.get(opts.owner, id);
|
|
30
|
+
if (!rec) {
|
|
31
|
+
opts.onMissing(id);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
found.push(rec);
|
|
35
|
+
}
|
|
36
|
+
if (found.length === 0)
|
|
37
|
+
return { notice: undefined };
|
|
38
|
+
await opts.store.bind(opts.owner, found.map((r) => r.id), opts.sessionId, opts.nowMs);
|
|
39
|
+
const all = await opts.store.listBySession(opts.sessionId);
|
|
40
|
+
const paths = materializedRelPaths(all);
|
|
41
|
+
const lines = found.map((r) => `- ${paths.get(r.id) ?? `attachments/${r.name}`} (${r.mime}, ${r.sizeBytes} bytes)`);
|
|
42
|
+
return { notice: `[attachments] ${found.length} uploaded file(s) are available in the \`attachments/\` directory of your working directory:\n${lines.join("\n")}` };
|
|
43
|
+
}
|
|
44
|
+
export async function materializeAttachmentsInto(env, store, sessionId) {
|
|
45
|
+
const atts = await store.listBySession(sessionId);
|
|
46
|
+
if (atts.length === 0)
|
|
47
|
+
return 0;
|
|
48
|
+
const paths = materializedRelPaths(atts);
|
|
49
|
+
const mk = await env.createDir("attachments", { recursive: true });
|
|
50
|
+
if (!mk.ok)
|
|
51
|
+
throw new Error(`attachment materialization failed (createDir attachments/): ${String(mk.error?.message ?? "unknown")}`);
|
|
52
|
+
for (const a of atts) {
|
|
53
|
+
const bytes = await store.getContent(a.owner, a.id);
|
|
54
|
+
if (!bytes)
|
|
55
|
+
throw new Error(`attachment materialization failed: content missing for ${a.id} (${a.name})`);
|
|
56
|
+
const w = await env.writeFile(paths.get(a.id), bytes);
|
|
57
|
+
if (!w.ok)
|
|
58
|
+
throw new Error(`attachment materialization failed (write ${paths.get(a.id)}): ${String(w.error?.message ?? "unknown")}`);
|
|
59
|
+
}
|
|
60
|
+
return atts.length;
|
|
61
|
+
}
|
|
62
|
+
export async function ensureTiDBTaskAttachmentSchema(pool) {
|
|
63
|
+
await pool.query(`CREATE TABLE IF NOT EXISTS ${TASK_ATTACHMENT_TABLE} (
|
|
64
|
+
id VARCHAR(64) NOT NULL,
|
|
65
|
+
-- owner = auth principal ?? "default"(单库 tenant 轴;server 端从凭据推导,非用户输入)
|
|
66
|
+
owner VARCHAR(190) NOT NULL,
|
|
67
|
+
name VARCHAR(255) NOT NULL,
|
|
68
|
+
mime VARCHAR(128) NOT NULL,
|
|
69
|
+
sha256 CHAR(64) NOT NULL,
|
|
70
|
+
size_bytes INT NOT NULL,
|
|
71
|
+
-- 绑定轴:提交 task 时钉上;NULL = 上传后未被引用(TTL 收割对象)
|
|
72
|
+
session_id VARCHAR(190) NULL,
|
|
73
|
+
created_at_ms BIGINT NOT NULL,
|
|
74
|
+
bound_at_ms BIGINT NULL,
|
|
75
|
+
PRIMARY KEY (id),
|
|
76
|
+
KEY idx_att_session (session_id),
|
|
77
|
+
KEY idx_att_unbound (created_at_ms)
|
|
78
|
+
) COLLATE utf8mb4_bin`);
|
|
79
|
+
}
|
|
80
|
+
export async function ensurePgTaskAttachmentSchema(q) {
|
|
81
|
+
await q(`CREATE TABLE IF NOT EXISTS ${TASK_ATTACHMENT_TABLE} (
|
|
82
|
+
id VARCHAR(64) NOT NULL,
|
|
83
|
+
-- owner:隔离键,'=' 必须字节等价(bga F2 同姿)
|
|
84
|
+
owner VARCHAR(190) COLLATE "C" NOT NULL,
|
|
85
|
+
name VARCHAR(255) NOT NULL,
|
|
86
|
+
mime VARCHAR(128) NOT NULL,
|
|
87
|
+
sha256 CHAR(64) NOT NULL,
|
|
88
|
+
size_bytes INT NOT NULL,
|
|
89
|
+
session_id VARCHAR(190),
|
|
90
|
+
created_at_ms BIGINT NOT NULL,
|
|
91
|
+
bound_at_ms BIGINT,
|
|
92
|
+
PRIMARY KEY (id)
|
|
93
|
+
)`);
|
|
94
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_att_session ON ${TASK_ATTACHMENT_TABLE} (session_id)`);
|
|
95
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_att_unbound ON ${TASK_ATTACHMENT_TABLE} (created_at_ms)`);
|
|
96
|
+
}
|
|
97
|
+
export class TiDBTaskAttachmentStore {
|
|
98
|
+
pool;
|
|
99
|
+
bytes;
|
|
100
|
+
constructor(pool, bytes) {
|
|
101
|
+
this.pool = pool;
|
|
102
|
+
this.bytes = bytes;
|
|
103
|
+
}
|
|
104
|
+
async put(rec) {
|
|
105
|
+
await this.bytes.putBlob(rec.sha256, rec.content);
|
|
106
|
+
await this.pool.execute(`INSERT INTO ${TASK_ATTACHMENT_TABLE} (${COLS}) VALUES (?,?,?,?,?,?,?,?,?)`, [rec.id, rec.owner, rec.name, rec.mime, rec.sha256, rec.sizeBytes, rec.sessionId ?? null, rec.createdAtMs, rec.boundAtMs ?? null]);
|
|
107
|
+
}
|
|
108
|
+
async get(owner, id) {
|
|
109
|
+
const [rows] = await this.pool.query(`SELECT ${COLS} FROM ${TASK_ATTACHMENT_TABLE} WHERE id = ? AND owner = ?`, [id, owner]);
|
|
110
|
+
return rows.length ? rowToRecord(rows[0]) : null;
|
|
111
|
+
}
|
|
112
|
+
async getContent(owner, id) {
|
|
113
|
+
const meta = await this.get(owner, id);
|
|
114
|
+
if (!meta)
|
|
115
|
+
return null;
|
|
116
|
+
const b = await this.bytes.getBlob(meta.sha256);
|
|
117
|
+
return b && b.byteLength === meta.sizeBytes ? b : null;
|
|
118
|
+
}
|
|
119
|
+
async deleteBytesIfUnreferenced(shas) {
|
|
120
|
+
for (const sha of new Set(shas)) {
|
|
121
|
+
try {
|
|
122
|
+
const [cnt] = await this.pool.query(`SELECT COUNT(*) AS n FROM ${TASK_ATTACHMENT_TABLE} WHERE sha256 = ?`, [sha]);
|
|
123
|
+
if (Number(cnt[0].n) === 0)
|
|
124
|
+
await this.bytes.deleteBlobs([sha]);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async bind(owner, ids, sessionId, boundAtMs) {
|
|
131
|
+
if (ids.length === 0)
|
|
132
|
+
return 0;
|
|
133
|
+
const [res] = await this.pool.query(`UPDATE ${TASK_ATTACHMENT_TABLE} SET session_id = ?, bound_at_ms = ? WHERE owner = ? AND id IN (${ids.map(() => "?").join(",")})`, [sessionId, boundAtMs, owner, ...ids]);
|
|
134
|
+
return res.affectedRows ?? 0;
|
|
135
|
+
}
|
|
136
|
+
async listBySession(sessionId) {
|
|
137
|
+
const [rows] = await this.pool.query(`SELECT ${COLS} FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id = ? ORDER BY created_at_ms ASC, id ASC`, [sessionId]);
|
|
138
|
+
return rows.map((r) => rowToRecord(r));
|
|
139
|
+
}
|
|
140
|
+
async delete(owner, id) {
|
|
141
|
+
const meta = await this.get(owner, id);
|
|
142
|
+
if (!meta)
|
|
143
|
+
return false;
|
|
144
|
+
const [res] = await this.pool.query(`DELETE FROM ${TASK_ATTACHMENT_TABLE} WHERE id = ? AND owner = ?`, [id, owner]);
|
|
145
|
+
if ((res.affectedRows ?? 0) === 0)
|
|
146
|
+
return false;
|
|
147
|
+
await this.deleteBytesIfUnreferenced([meta.sha256]);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
async deleteBySession(sessionId) {
|
|
151
|
+
const [rows] = await this.pool.query(`SELECT sha256 FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id = ?`, [sessionId]);
|
|
152
|
+
const [res] = await this.pool.query(`DELETE FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id = ?`, [sessionId]);
|
|
153
|
+
await this.deleteBytesIfUnreferenced(rows.map((r) => r.sha256));
|
|
154
|
+
return res.affectedRows ?? 0;
|
|
155
|
+
}
|
|
156
|
+
async reapUnbound(olderThanMs) {
|
|
157
|
+
const [rows] = await this.pool.query(`SELECT sha256 FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id IS NULL AND created_at_ms < ?`, [olderThanMs]);
|
|
158
|
+
const [res] = await this.pool.query(`DELETE FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id IS NULL AND created_at_ms < ?`, [olderThanMs]);
|
|
159
|
+
await this.deleteBytesIfUnreferenced(rows.map((r) => r.sha256));
|
|
160
|
+
return res.affectedRows ?? 0;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export class PgTaskAttachmentStore {
|
|
164
|
+
q;
|
|
165
|
+
bytes;
|
|
166
|
+
constructor(q, bytes) {
|
|
167
|
+
this.q = q;
|
|
168
|
+
this.bytes = bytes;
|
|
169
|
+
}
|
|
170
|
+
async put(rec) {
|
|
171
|
+
await this.bytes.putBlob(rec.sha256, rec.content);
|
|
172
|
+
await this.q(`INSERT INTO ${TASK_ATTACHMENT_TABLE} (${COLS}) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [rec.id, rec.owner, rec.name, rec.mime, rec.sha256, rec.sizeBytes, rec.sessionId ?? null, rec.createdAtMs, rec.boundAtMs ?? null]);
|
|
173
|
+
}
|
|
174
|
+
async get(owner, id) {
|
|
175
|
+
const res = await this.q(`SELECT ${COLS} FROM ${TASK_ATTACHMENT_TABLE} WHERE id = $1 AND owner = $2`, [id, owner]);
|
|
176
|
+
return res.rows.length ? rowToRecord(res.rows[0]) : null;
|
|
177
|
+
}
|
|
178
|
+
async getContent(owner, id) {
|
|
179
|
+
const meta = await this.get(owner, id);
|
|
180
|
+
if (!meta)
|
|
181
|
+
return null;
|
|
182
|
+
const b = await this.bytes.getBlob(meta.sha256);
|
|
183
|
+
return b && b.byteLength === meta.sizeBytes ? b : null;
|
|
184
|
+
}
|
|
185
|
+
async deleteBytesIfUnreferenced(shas) {
|
|
186
|
+
for (const sha of new Set(shas)) {
|
|
187
|
+
try {
|
|
188
|
+
const res = await this.q(`SELECT COUNT(*) AS n FROM ${TASK_ATTACHMENT_TABLE} WHERE sha256 = $1`, [sha]);
|
|
189
|
+
if (Number(res.rows[0].n) === 0)
|
|
190
|
+
await this.bytes.deleteBlobs([sha]);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async bind(owner, ids, sessionId, boundAtMs) {
|
|
197
|
+
if (ids.length === 0)
|
|
198
|
+
return 0;
|
|
199
|
+
const res = await this.q(`UPDATE ${TASK_ATTACHMENT_TABLE} SET session_id = $1, bound_at_ms = $2 WHERE owner = $3 AND id = ANY($4::varchar[]) RETURNING id`, [sessionId, boundAtMs, owner, ids]);
|
|
200
|
+
return res.rows.length;
|
|
201
|
+
}
|
|
202
|
+
async listBySession(sessionId) {
|
|
203
|
+
const res = await this.q(`SELECT ${COLS} FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id = $1 ORDER BY created_at_ms ASC, id ASC`, [sessionId]);
|
|
204
|
+
return res.rows.map((r) => rowToRecord(r));
|
|
205
|
+
}
|
|
206
|
+
async delete(owner, id) {
|
|
207
|
+
const res = await this.q(`DELETE FROM ${TASK_ATTACHMENT_TABLE} WHERE id = $1 AND owner = $2 RETURNING sha256`, [id, owner]);
|
|
208
|
+
if (res.rows.length === 0)
|
|
209
|
+
return false;
|
|
210
|
+
await this.deleteBytesIfUnreferenced([res.rows[0].sha256]);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
async deleteBySession(sessionId) {
|
|
214
|
+
const res = await this.q(`DELETE FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id = $1 RETURNING sha256`, [sessionId]);
|
|
215
|
+
await this.deleteBytesIfUnreferenced(res.rows.map((r) => r.sha256));
|
|
216
|
+
return res.rows.length;
|
|
217
|
+
}
|
|
218
|
+
async reapUnbound(olderThanMs) {
|
|
219
|
+
const res = await this.q(`DELETE FROM ${TASK_ATTACHMENT_TABLE} WHERE session_id IS NULL AND created_at_ms < $1 RETURNING sha256`, [olderThanMs]);
|
|
220
|
+
await this.deleteBytesIfUnreferenced(res.rows.map((r) => r.sha256));
|
|
221
|
+
return res.rows.length;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=task-attachment-store.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.289.0",
|
|
4
4
|
"description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BUSL-1.1",
|