@sema-agent/server 1.301.0 → 1.302.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.
@@ -3291,11 +3291,11 @@ export function createHttpServer(deps) {
3291
3291
  scope = decodeURIComponent(url.slice("/v1/memory/sync/".length));
3292
3292
  }
3293
3293
  catch {
3294
- sendJson(res, 400, { error: "malformed percent-encoding in :scope", code: "memory_sync_invalid_scope" });
3294
+ sendJson(res, 400, { error: "malformed percent-encoding in :scope", code: "memory_sync_invalid_scope", errorCode: "memory_sync_invalid_scope" });
3295
3295
  return;
3296
3296
  }
3297
3297
  if (scope.length === 0) {
3298
- sendJson(res, 400, { error: "missing :scope", code: "memory_sync_invalid_scope" });
3298
+ sendJson(res, 400, { error: "missing :scope", code: "memory_sync_invalid_scope", errorCode: "memory_sync_invalid_scope" });
3299
3299
  return;
3300
3300
  }
3301
3301
  if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
@@ -3305,7 +3305,7 @@ export function createHttpServer(deps) {
3305
3305
  const body = await readJson(req);
3306
3306
  const parsed = parseMemorySyncRequest(body, scope);
3307
3307
  if (!parsed.ok) {
3308
- sendJson(res, 422, { error: parsed.error, code: "memory_sync_invalid_body" });
3308
+ sendJson(res, 422, { error: parsed.error, code: "memory_sync_invalid_body", errorCode: "memory_sync_invalid_body" });
3309
3309
  return;
3310
3310
  }
3311
3311
  try {
@@ -4042,7 +4042,7 @@ export function createHttpServer(deps) {
4042
4042
  if (typeof fs.blobSizes === "function") {
4043
4043
  const known = (await fs.blobSizes([hash])).get(hash);
4044
4044
  if (known !== undefined && known > cap) {
4045
- sendJson(res, 413, { error: "file exceeds the single-file read limit — download it via the archive endpoint", code: "workspace_file_too_large", sizeBytes: known, limit: cap });
4045
+ sendJson(res, 413, { error: "file exceeds the single-file read limit — download it via the archive endpoint", code: "workspace_file_too_large", errorCode: "workspace_file_too_large", sizeBytes: known, limit: cap });
4046
4046
  return;
4047
4047
  }
4048
4048
  }
@@ -4052,7 +4052,7 @@ export function createHttpServer(deps) {
4052
4052
  return;
4053
4053
  }
4054
4054
  if (bytes.byteLength > cap) {
4055
- sendJson(res, 413, { error: "file exceeds the single-file read limit — download it via the archive endpoint", code: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
4055
+ sendJson(res, 413, { error: "file exceeds the single-file read limit — download it via the archive endpoint", code: "workspace_file_too_large", errorCode: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
4056
4056
  return;
4057
4057
  }
4058
4058
  const ct = workspaceContentType(pathParam);
@@ -4259,7 +4259,7 @@ export function createHttpServer(deps) {
4259
4259
  }
4260
4260
  const put = await fs.putBlob(hash, new Uint8Array(body));
4261
4261
  if (!put.ok) {
4262
- sendJson(res, 502, { error: "blob store write failed", code: put.error.code });
4262
+ sendJson(res, 502, { error: "blob store write failed", code: put.error.code, errorCode: put.error.code });
4263
4263
  return;
4264
4264
  }
4265
4265
  res.writeHead(204).end();
@@ -4626,7 +4626,7 @@ export function createHttpServer(deps) {
4626
4626
  }
4627
4627
  const allow = deps.config.attachmentMimeAllowlist;
4628
4628
  if (allow && !allow.some((a) => a === mime || (a.endsWith("/*") && mime.startsWith(a.slice(0, -1))))) {
4629
- sendJson(res, 415, { code: "attachment.mime_not_allowed", error: `mime '${mime}' is not in this deployment's allowlist`, allowed: allow });
4629
+ sendJson(res, 415, { code: "attachment.mime_not_allowed", errorCode: "attachment.mime_not_allowed", error: `mime '${mime}' is not in this deployment's allowlist`, allowed: allow });
4630
4630
  return;
4631
4631
  }
4632
4632
  const max = deps.config.attachmentMaxBytes ?? 32 * 1024 * 1024;
@@ -4636,7 +4636,7 @@ export function createHttpServer(deps) {
4636
4636
  }
4637
4637
  catch (err) {
4638
4638
  if (err instanceof HttpError && err.status === 413) {
4639
- sendJson(res, 413, { code: "attachment.too_large", error: `attachment exceeds the per-file limit (${max} bytes)`, maxBytes: max });
4639
+ sendJson(res, 413, { code: "attachment.too_large", errorCode: "attachment.too_large", error: `attachment exceeds the per-file limit (${max} bytes)`, maxBytes: max });
4640
4640
  return;
4641
4641
  }
4642
4642
  throw err;
@@ -5260,7 +5260,7 @@ export function createHttpServer(deps) {
5260
5260
  if (e instanceof HttpError) {
5261
5261
  return {
5262
5262
  status: 409,
5263
- body: { error: `resume blocked by an admission policy change: ${e.message}`, code: "resume_blocked_by_policy", ...(e.code ? { blockedBy: e.code } : {}), ...(e.extra ?? {}) },
5263
+ body: { error: `resume blocked by an admission policy change: ${e.message}`, code: "resume_blocked_by_policy", errorCode: "resume_blocked_by_policy", ...(e.code ? { blockedBy: e.code } : {}), ...(e.extra ?? {}) },
5264
5264
  };
5265
5265
  }
5266
5266
  throw e;
@@ -5646,9 +5646,9 @@ export function createHttpServer(deps) {
5646
5646
  body: {
5647
5647
  error: e.message,
5648
5648
  code: e.code,
5649
- ...(e.code === "checkpoint.invalid_outcome"
5650
- ? { errorCode: outcome.gate === "policy_ask" ? "approval_binding_mismatch" : "resume_outcome_invalid" }
5651
- : {}),
5649
+ errorCode: e.code === "checkpoint.invalid_outcome"
5650
+ ? (outcome.gate === "policy_ask" ? "approval_binding_mismatch" : "resume_outcome_invalid")
5651
+ : e.code,
5652
5652
  ...(retriable ? { retriable: true } : {}),
5653
5653
  },
5654
5654
  };
@@ -5888,7 +5888,7 @@ export function createHttpServer(deps) {
5888
5888
  return false;
5889
5889
  deps.metrics?.inc("fleet_lease_rejected_total");
5890
5890
  res.setHeader("retry-after", String(adm.retryAfterSec));
5891
- sendJson(res, 429, { error: "budget exhausted (quota lease)", code: "quota_exhausted", retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
5891
+ sendJson(res, 429, { error: "budget exhausted (quota lease)", code: "quota_exhausted", errorCode: "quota_exhausted", retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
5892
5892
  return true;
5893
5893
  }
5894
5894
  function rateLimited(req, res) {
package/dist/main.js CHANGED
@@ -1440,6 +1440,12 @@ async function main() {
1440
1440
  void toolResultStore?.reapOlderThan?.(Date.now() - config.toolResultTtlSec * 1000)?.catch(() => undefined);
1441
1441
  void fileSnapshotStore?.sweepOrphanBlobs?.().catch(() => undefined);
1442
1442
  void taskAttachmentStore?.reapUnbound(Date.now() - config.attachmentUnboundTtlMs).then(reapCount("attachments_reaped_total", {})).catch(() => undefined);
1443
+ void taskAttachmentStore?.sweepOrphanObjects?.(3_600_000)
1444
+ .then((n) => { if (n > 0) {
1445
+ metrics.inc("attachment_orphan_objects_swept_total", {}, n);
1446
+ logger.info("attachment_orphan_objects_swept", { removed: n });
1447
+ } })
1448
+ .catch((err) => logger.warn("attachment_orphan_sweep_failed", { err: String(err) }));
1443
1449
  void backend?.session()?.sweepStagingSessions?.().catch(() => undefined);
1444
1450
  void imageBakes?.reapStaleBakes(config.imageBakes.staleMs).then(reapCount("bakes_reaped_total", {})).catch(() => undefined);
1445
1451
  void worktreeReap?.();
@@ -30,6 +30,7 @@ export interface MinioBlobBackendConfig {
30
30
  region?: string;
31
31
  keyPrefix?: string;
32
32
  presignTtlSec?: number;
33
+ fetchImpl?: typeof fetch;
33
34
  }
34
35
  export declare class MinioBlobBackend implements BlobBackend {
35
36
  private readonly cfg;
@@ -46,6 +47,10 @@ export declare class MinioBlobBackend implements BlobBackend {
46
47
  putBlob(hash: string, bytes: Uint8Array): Promise<void>;
47
48
  getBlob(hash: string): Promise<Uint8Array | undefined>;
48
49
  hasBlobs(hashes: string[]): Promise<Set<string>>;
50
+ listObjects(): AsyncGenerator<{
51
+ hash: string;
52
+ lastModifiedMs: number;
53
+ }>;
49
54
  deleteBlobs(hashes: string[]): Promise<number>;
50
55
  }
51
56
  //# sourceMappingURL=blob-backend.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { presignS3Url } from "./s3-presign.js";
2
+ import { presignS3Url, presignS3ListUrl } from "./s3-presign.js";
3
3
  const BLOB_GC_GRACE_MS = 3_600_000;
4
4
  export const SQL_BLOB_DEFAULT_MAX_BYTES = 6_291_456;
5
5
  export class BlobTooLargeError extends Error {
@@ -191,6 +191,40 @@ export class MinioBlobBackend {
191
191
  });
192
192
  return present;
193
193
  }
194
+ async *listObjects() {
195
+ const f = this.cfg.fetchImpl ?? fetch;
196
+ let token;
197
+ do {
198
+ const url = presignS3ListUrl({
199
+ endpoint: this.cfg.endpoint,
200
+ bucket: this.cfg.bucket,
201
+ prefix: this.keyPrefix,
202
+ ...(token ? { continuationToken: token } : {}),
203
+ maxKeys: 1000,
204
+ accessKey: this.cfg.accessKey,
205
+ secretKey: this.cfg.secretKey,
206
+ ...(this.cfg.region ? { region: this.cfg.region } : {}),
207
+ expiresSec: this.ttl,
208
+ });
209
+ const res = await f(url, { method: "GET" });
210
+ if (!res.ok)
211
+ throw new Error(`listObjects: MinIO ${res.status}`);
212
+ const xml = await res.text();
213
+ for (const m of xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)) {
214
+ const block = m[1];
215
+ const key = /<Key>([^<]*)<\/Key>/.exec(block)?.[1];
216
+ const lm = /<LastModified>([^<]*)<\/LastModified>/.exec(block)?.[1];
217
+ if (key === undefined || !key.startsWith(this.keyPrefix))
218
+ continue;
219
+ const ms = lm ? Date.parse(lm) : NaN;
220
+ yield { hash: key.slice(this.keyPrefix.length), lastModifiedMs: Number.isFinite(ms) ? ms : Date.now() };
221
+ }
222
+ const truncated = /<IsTruncated>true<\/IsTruncated>/.test(xml);
223
+ token = truncated ? /<NextContinuationToken>([^<]*)<\/NextContinuationToken>/.exec(xml)?.[1] : undefined;
224
+ if (truncated && token === undefined)
225
+ throw new Error("listObjects: truncated response without NextContinuationToken");
226
+ } while (token !== undefined);
227
+ }
194
228
  async deleteBlobs(hashes) {
195
229
  if (hashes.length === 0)
196
230
  return 0;
@@ -21,6 +21,18 @@ export interface S3BucketPresignParams {
21
21
  now?: Date;
22
22
  }
23
23
  export declare function presignS3BucketUrl(p: S3BucketPresignParams): string;
24
+ export declare function presignS3ListUrl(p: {
25
+ endpoint: string;
26
+ bucket: string;
27
+ prefix: string;
28
+ continuationToken?: string;
29
+ maxKeys?: number;
30
+ accessKey: string;
31
+ secretKey: string;
32
+ region?: string;
33
+ expiresSec?: number;
34
+ now?: Date;
35
+ }): string;
24
36
  export interface S3CopyPresignParams {
25
37
  endpoint: string;
26
38
  bucket: string;
@@ -35,6 +35,7 @@ function presignCanonical(p) {
35
35
  const headerNames = [...headerMap.keys()].sort();
36
36
  const signedHeaderList = headerNames.join(";");
37
37
  const params = {
38
+ ...(p.extraQuery ?? {}),
38
39
  "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
39
40
  "X-Amz-Credential": `${p.accessKey}/${scope}`,
40
41
  "X-Amz-Date": amzDate,
@@ -67,6 +68,24 @@ export function presignS3Url(p) {
67
68
  export function presignS3BucketUrl(p) {
68
69
  return presignCanonical({ ...p, canonicalUri: `/${uriEncode(p.bucket, true)}/` });
69
70
  }
71
+ export function presignS3ListUrl(p) {
72
+ return presignCanonical({
73
+ endpoint: p.endpoint,
74
+ canonicalUri: `/${uriEncode(p.bucket, true)}/`,
75
+ method: "GET",
76
+ accessKey: p.accessKey,
77
+ secretKey: p.secretKey,
78
+ ...(p.region ? { region: p.region } : {}),
79
+ ...(p.expiresSec ? { expiresSec: p.expiresSec } : {}),
80
+ ...(p.now ? { now: p.now } : {}),
81
+ extraQuery: {
82
+ "list-type": "2",
83
+ prefix: p.prefix,
84
+ ...(p.continuationToken ? { "continuation-token": p.continuationToken } : {}),
85
+ ...(p.maxKeys ? { "max-keys": String(p.maxKeys) } : {}),
86
+ },
87
+ });
88
+ }
70
89
  export function presignS3CopyUrl(p) {
71
90
  for (const [label, key] of [
72
91
  ["destination", p.key],
@@ -22,12 +22,17 @@ export interface TaskAttachmentStore {
22
22
  delete(owner: string, id: string): Promise<boolean>;
23
23
  deleteBySession(sessionId: string): Promise<number>;
24
24
  reapUnbound(olderThanMs: number): Promise<number>;
25
+ sweepOrphanObjects?(graceMs: number, nowMs?: number): Promise<number>;
25
26
  }
26
27
  export declare const TASK_ATTACHMENT_TABLE = "task_attachment";
27
28
  export interface AttachmentBytesStore {
28
29
  putBlob(hash: string, bytes: Uint8Array): Promise<void>;
29
30
  getBlob(hash: string): Promise<Uint8Array | undefined>;
30
31
  deleteBlobs(hashes: string[]): Promise<number>;
32
+ listObjects?(): AsyncGenerator<{
33
+ hash: string;
34
+ lastModifiedMs: number;
35
+ }>;
31
36
  }
32
37
  export declare function materializedRelPaths(atts: TaskAttachmentRecord[]): Map<string, string>;
33
38
  export declare function bindAttachmentsForTask(opts: {
@@ -78,6 +83,8 @@ export declare class TiDBTaskAttachmentStore implements TaskAttachmentStore {
78
83
  delete(owner: string, id: string): Promise<boolean>;
79
84
  deleteBySession(sessionId: string): Promise<number>;
80
85
  reapUnbound(olderThanMs: number): Promise<number>;
86
+ sweepOrphanObjects(graceMs: number, nowMs?: number): Promise<number>;
87
+ private referencedShas;
81
88
  }
82
89
  export declare class PgTaskAttachmentStore implements TaskAttachmentStore {
83
90
  private readonly q;
@@ -94,5 +101,7 @@ export declare class PgTaskAttachmentStore implements TaskAttachmentStore {
94
101
  delete(owner: string, id: string): Promise<boolean>;
95
102
  deleteBySession(sessionId: string): Promise<number>;
96
103
  reapUnbound(olderThanMs: number): Promise<number>;
104
+ sweepOrphanObjects(graceMs: number, nowMs?: number): Promise<number>;
105
+ private referencedShas;
97
106
  }
98
107
  //# sourceMappingURL=task-attachment-store.d.ts.map
@@ -159,6 +159,32 @@ export class TiDBTaskAttachmentStore {
159
159
  await this.deleteBytesIfUnreferenced(rows.map((r) => r.sha256));
160
160
  return res.affectedRows ?? 0;
161
161
  }
162
+ async sweepOrphanObjects(graceMs, nowMs = Date.now()) {
163
+ const lister = this.bytes;
164
+ if (typeof lister.listObjects !== "function")
165
+ return 0;
166
+ const candidates = [];
167
+ for await (const o of lister.listObjects()) {
168
+ if (nowMs - o.lastModifiedMs >= graceMs)
169
+ candidates.push(o.hash);
170
+ }
171
+ let removed = 0;
172
+ for (let i = 0; i < candidates.length; i += 200) {
173
+ const batch = candidates.slice(i, i + 200);
174
+ const referenced = await this.referencedShas(batch);
175
+ const orphans = batch.filter((h) => !referenced.has(h));
176
+ if (orphans.length > 0)
177
+ removed += await this.bytes.deleteBlobs(orphans);
178
+ }
179
+ return removed;
180
+ }
181
+ async referencedShas(shas) {
182
+ if (shas.length === 0)
183
+ return new Set();
184
+ const ph = shas.map(() => "?").join(",");
185
+ const [rows] = await this.pool.query(`SELECT DISTINCT sha256 FROM ${TASK_ATTACHMENT_TABLE} WHERE sha256 IN (${ph})`, shas);
186
+ return new Set(rows.map((r) => String(r.sha256)));
187
+ }
162
188
  }
163
189
  export class PgTaskAttachmentStore {
164
190
  q;
@@ -220,5 +246,31 @@ export class PgTaskAttachmentStore {
220
246
  await this.deleteBytesIfUnreferenced(res.rows.map((r) => r.sha256));
221
247
  return res.rows.length;
222
248
  }
249
+ async sweepOrphanObjects(graceMs, nowMs = Date.now()) {
250
+ const lister = this.bytes;
251
+ if (typeof lister.listObjects !== "function")
252
+ return 0;
253
+ const candidates = [];
254
+ for await (const o of lister.listObjects()) {
255
+ if (nowMs - o.lastModifiedMs >= graceMs)
256
+ candidates.push(o.hash);
257
+ }
258
+ let removed = 0;
259
+ for (let i = 0; i < candidates.length; i += 200) {
260
+ const batch = candidates.slice(i, i + 200);
261
+ const referenced = await this.referencedShas(batch);
262
+ const orphans = batch.filter((h) => !referenced.has(h));
263
+ if (orphans.length > 0)
264
+ removed += await this.bytes.deleteBlobs(orphans);
265
+ }
266
+ return removed;
267
+ }
268
+ async referencedShas(shas) {
269
+ if (shas.length === 0)
270
+ return new Set();
271
+ const ph = shas.map((_, i) => `$${i + 1}`).join(",");
272
+ const r = await this.q(`SELECT DISTINCT sha256 FROM ${TASK_ATTACHMENT_TABLE} WHERE sha256 IN (${ph})`, shas);
273
+ return new Set(r.rows.map((row) => String(row.sha256)));
274
+ }
223
275
  }
224
276
  //# 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.301.0",
3
+ "version": "1.302.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",