@sema-agent/server 1.288.0 → 1.290.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 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),
@@ -8,6 +8,7 @@ export interface FleetTaskRow {
8
8
  scope?: string;
9
9
  sessionId?: string;
10
10
  parentId?: string;
11
+ parentToolCallId?: string;
11
12
  workflowRunId?: string;
12
13
  status: FleetTaskStatus;
13
14
  startedAt?: number;
@@ -223,6 +223,7 @@ export function fleetBackgroundChildPublisher(bus, log) {
223
223
  ...(m.hostSessionId ? { sessionId: m.hostSessionId } : {}),
224
224
  ...(parentId !== undefined ? { parentId } : {}),
225
225
  ...(m.workflowRunId !== undefined ? { workflowRunId: m.workflowRunId } : {}),
226
+ ...(m.parentToolCallId !== undefined ? { parentToolCallId: m.parentToolCallId } : {}),
226
227
  ...(m.spawnedAt !== undefined ? { startedAt: m.spawnedAt, elapsedMs: Date.now() - m.spawnedAt } : {}),
227
228
  };
228
229
  };
@@ -250,6 +251,7 @@ export function fleetBackgroundChildPublisher(bus, log) {
250
251
  sessionScoped: e.sessionScoped,
251
252
  spawnedAt: e.startedAt ?? Date.now(),
252
253
  ...(e.rootSessionId ? { rootSessionId: e.rootSessionId } : {}),
254
+ ...(e.parentToolCallId ? { parentToolCallId: e.parentToolCallId } : {}),
253
255
  ...(e.sessionId ? { aliasUuid: e.sessionId } : {}),
254
256
  ...(e.workflowRunId ? { workflowRunId: e.workflowRunId } : {}),
255
257
  ...(e.workflowRunId && !(e.sessionScoped && e.owner)
@@ -293,6 +295,8 @@ export function fleetBackgroundChildPublisher(bus, log) {
293
295
  dlog("bg_child_event", { kind: `${e.kind}-after-terminal-ignored`, taskId: e.taskId });
294
296
  return;
295
297
  }
298
+ if (m.parentToolCallId === undefined && e.parentToolCallId)
299
+ m.parentToolCallId = e.parentToolCallId;
296
300
  if (e.kind === "tick") {
297
301
  if (!m.suppressedTwin && e.progressTaskId && (!e.progressParentTaskId || e.progressParentTaskId === (m.parentTaskId ?? m.parentSessionLink))) {
298
302
  const tail = ` ${e.progressTaskId}`;
@@ -362,6 +366,7 @@ export function fleetBackgroundChildPublisher(bus, log) {
362
366
  }
363
367
  : {}),
364
368
  ...(e.transcriptId ? { transcriptId: e.transcriptId } : {}),
369
+ ...(e.parentToolCallId ? { parentToolCallId: e.parentToolCallId } : {}),
365
370
  ...(m.tenantScope ? { ownerScope: m.tenantScope } : {}),
366
371
  ...(m.hostSessionId ? { ownerSessionId: m.hostSessionId } : {}),
367
372
  ...(m.rootSessionId ? { rootSessionId: m.rootSessionId } : {}),
@@ -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;
@@ -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
@@ -2,7 +2,7 @@ import type { TaskNotificationPayload, BackgroundChildEvent, RosterEntry, AskReq
2
2
  type AssertAllKeysHandled<T extends never> = T;
3
3
  type NotificationProjected = "task_id" | "task_type" | "toolUseId" | "status" | "summary" | "result" | "output_file" | "usage" | "sessionId" | "seq" | "lines" | "stoppedBy" | "source" | "exitCode" | "partial" | "diagnostics" | "recentSteps" | "editedFiles" | "resumable";
4
4
  type _GuardNotification = AssertAllKeysHandled<Exclude<keyof TaskNotificationPayload, NotificationProjected>>;
5
- type BgNotifProjected = "taskId" | "sessionId" | "seq" | "status" | "summary" | "stoppedBy" | "resumable" | "recentSteps" | "editedFiles" | "usage" | "transcriptId" | "rootSessionId" | "parentTaskId";
5
+ type BgNotifProjected = "taskId" | "sessionId" | "seq" | "status" | "summary" | "stoppedBy" | "resumable" | "recentSteps" | "editedFiles" | "usage" | "transcriptId" | "rootSessionId" | "parentTaskId" | "parentToolCallId";
6
6
  type BgNotifExcluded = "kind" | "sessionScoped" | "owner" | "scope" | "description" | "agentType" | "name" | "currentAction" | "currentTool" | "parentSessionId" | "startedAt" | "workflowRunId" | "progressTaskId" | "progressParentTaskId";
7
7
  type _GuardBgNotif = AssertAllKeysHandled<Exclude<keyof BackgroundChildEvent, BgNotifProjected | BgNotifExcluded>>;
8
8
  type RosterProjected = "name" | "agentId" | "sessionId" | "toolUseId" | "owner" | "scope" | "sessionScoped" | "rootSessionId" | "model" | "createdAt";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "1.288.0",
3
+ "version": "1.290.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",