agent-relay-server 0.39.0 → 0.41.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/docs/openapi.json +29 -1
- package/package.json +2 -2
- package/public/assets/{activity-b8kUF4hR.js → activity-DAz3DcDA.js} +2 -2
- package/public/assets/{activity-b8kUF4hR.js.map → activity-DAz3DcDA.js.map} +1 -1
- package/public/assets/{agents-C_ZSRXRa.js → agents-BJ7qRWxt.js} +2 -2
- package/public/assets/{agents-C_ZSRXRa.js.map → agents-BJ7qRWxt.js.map} +1 -1
- package/public/assets/{analytics-3JbZ6M41.js → analytics-MNCS2qnT.js} +2 -2
- package/public/assets/{analytics-3JbZ6M41.js.map → analytics-MNCS2qnT.js.map} +1 -1
- package/public/assets/{automation-CVu2C3sk.js → automation-COp2Kb3h.js} +2 -2
- package/public/assets/{automation-CVu2C3sk.js.map → automation-COp2Kb3h.js.map} +1 -1
- package/public/assets/chat-D_O8VqMR.js +2 -0
- package/public/assets/chat-D_O8VqMR.js.map +1 -0
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/public/assets/{formatted-body-impl-CSAGn8zM.js → formatted-body-impl-BbMHqkCy.js} +2 -2
- package/public/assets/{formatted-body-impl-CSAGn8zM.js.map → formatted-body-impl-BbMHqkCy.js.map} +1 -1
- package/public/assets/{index-n5wxGWSq.js → index-DgJOEApM.js} +5 -5
- package/public/assets/{index-n5wxGWSq.js.map → index-DgJOEApM.js.map} +1 -1
- package/public/assets/{index-Bins8N_5.css → index-Dop-uXiy.css} +1 -1
- package/public/assets/{maintenance-CCcvsBbm.js → maintenance-BHv90kXZ.js} +2 -2
- package/public/assets/{maintenance-CCcvsBbm.js.map → maintenance-BHv90kXZ.js.map} +1 -1
- package/public/assets/{managed-agents-DHfO5gVD.js → managed-agents-BAlvkxfo.js} +2 -2
- package/public/assets/{managed-agents-DHfO5gVD.js.map → managed-agents-BAlvkxfo.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C1jNXAEL.js → markdown-preview-impl-6k_FWjmd.js} +2 -2
- package/public/assets/{markdown-preview-impl-C1jNXAEL.js.map → markdown-preview-impl-6k_FWjmd.js.map} +1 -1
- package/public/assets/{memory-VmjJ-YZ8.js → memory-DBjqdVpg.js} +2 -2
- package/public/assets/{memory-VmjJ-YZ8.js.map → memory-DBjqdVpg.js.map} +1 -1
- package/public/assets/{messages-DXN6Rkjt.js → messages-axoaJY2N.js} +2 -2
- package/public/assets/{messages-DXN6Rkjt.js.map → messages-axoaJY2N.js.map} +1 -1
- package/public/assets/{orchestrators-CLyYvfn1.js → orchestrators-DMfHZ44H.js} +2 -2
- package/public/assets/{orchestrators-CLyYvfn1.js.map → orchestrators-DMfHZ44H.js.map} +1 -1
- package/public/assets/{overview-DZmVU2Fh.js → overview-DqxYWpUT.js} +2 -2
- package/public/assets/{overview-DZmVU2Fh.js.map → overview-DqxYWpUT.js.map} +1 -1
- package/public/assets/{pairs-BeEORfZF.js → pairs-D3wnzC1V.js} +2 -2
- package/public/assets/{pairs-BeEORfZF.js.map → pairs-D3wnzC1V.js.map} +1 -1
- package/public/assets/{security-DIe-a7rD.js → security-KWrb7PKj.js} +2 -2
- package/public/assets/{security-DIe-a7rD.js.map → security-KWrb7PKj.js.map} +1 -1
- package/public/assets/{settings-BeFPCFUZ.js → settings-PXMEzNAm.js} +2 -2
- package/public/assets/{settings-BeFPCFUZ.js.map → settings-PXMEzNAm.js.map} +1 -1
- package/public/assets/{tasks-BVXiBzUI.js → tasks-CFYShBCz.js} +2 -2
- package/public/assets/{tasks-BVXiBzUI.js.map → tasks-CFYShBCz.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CN311GDZ.js → terminal-viewer-impl-BwPYZlWI.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CN311GDZ.js.map → terminal-viewer-impl-BwPYZlWI.js.map} +1 -1
- package/public/assets/{work-queue-BnQftDAS.js → work-queue-VQ_6QDJc.js} +2 -2
- package/public/assets/{work-queue-BnQftDAS.js.map → work-queue-VQ_6QDJc.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/src/adapter.ts +12 -3
- package/src/bus.ts +2 -0
- package/src/config-store.ts +20 -8
- package/src/context-advisory.ts +110 -0
- package/src/context-threshold-config.ts +40 -0
- package/src/db/agents.ts +21 -5
- package/src/db/continuation-archives.ts +179 -0
- package/src/db/continuations.ts +151 -0
- package/src/db/index.ts +2 -0
- package/src/db/messages.ts +36 -2
- package/src/db/migrations.ts +34 -0
- package/src/db/schema.ts +24 -0
- package/src/lifecycle-manager.ts +2 -2
- package/src/maintenance.ts +11 -17
- package/src/mcp.ts +77 -0
- package/src/routes/commands.ts +24 -0
- package/src/routes/continuation-archives.ts +37 -0
- package/src/routes/index.ts +2 -0
- package/src/security.ts +2 -0
- package/src/self-resume-config.ts +44 -0
- package/src/self-resume.ts +232 -0
- package/src/services/send-message.ts +4 -0
- package/src/workspace-pr-completion.ts +121 -0
- package/public/assets/chat-BiCWbMPO.js +0 -2
- package/public/assets/chat-BiCWbMPO.js.map +0 -1
package/src/mcp.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
listAgents,
|
|
25
25
|
listWorkspaces,
|
|
26
26
|
ownedIsolatedWorkspace,
|
|
27
|
+
searchContinuationArchives,
|
|
27
28
|
searchAgents,
|
|
28
29
|
type AgentSearchFilter,
|
|
29
30
|
type AgentSearchSort,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
import { resolveCallerAgentId, type DeliveryReceipt } from "./agent-ref";
|
|
36
37
|
import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "./services/shutdown-agent";
|
|
37
38
|
import { emitRelayEvent } from "./events";
|
|
39
|
+
import { compactAndResume } from "./self-resume";
|
|
38
40
|
import {
|
|
39
41
|
getComponentAuth,
|
|
40
42
|
getIntegrationAuth,
|
|
@@ -246,6 +248,52 @@ const TOOLS: ToolDefinition[] = [
|
|
|
246
248
|
additionalProperties: false,
|
|
247
249
|
},
|
|
248
250
|
},
|
|
251
|
+
{
|
|
252
|
+
name: "relay_compact_and_resume",
|
|
253
|
+
description: "Self-resume this same agent in place: Relay stores the continuation envelope, compacts or clears context, then resumes with the relay-built continuation. Use at a clean seam after a context advisory.",
|
|
254
|
+
requiredScopes: ["command:write"],
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: {
|
|
258
|
+
workingState: { type: "string", description: "Current replaceable working state for the next generation." },
|
|
259
|
+
ruledOut: {
|
|
260
|
+
type: "array",
|
|
261
|
+
items: {
|
|
262
|
+
oneOf: [
|
|
263
|
+
{ type: "string" },
|
|
264
|
+
{
|
|
265
|
+
type: "object",
|
|
266
|
+
properties: {
|
|
267
|
+
ruledOut: { type: "string" },
|
|
268
|
+
why: { type: "string" },
|
|
269
|
+
},
|
|
270
|
+
required: ["ruledOut"],
|
|
271
|
+
additionalProperties: false,
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
mode: { type: "string", enum: ["clear-inject", "native"] },
|
|
277
|
+
objective: { type: "string", description: "Seed-only objective. Used only on the first call when no spawn prompt seeded the envelope." },
|
|
278
|
+
},
|
|
279
|
+
required: ["workingState"],
|
|
280
|
+
additionalProperties: false,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: "relay_recall",
|
|
285
|
+
description: "Recall this same agent's archived pre-compaction transcript segments by keyword/BM25 search. Self-only: caller identity comes from the MCP token; no agentId parameter is accepted.",
|
|
286
|
+
requiredScopes: ["memory:read"],
|
|
287
|
+
inputSchema: {
|
|
288
|
+
type: "object",
|
|
289
|
+
properties: {
|
|
290
|
+
query: { type: "string", description: "Keyword query over archived compact/clear segments." },
|
|
291
|
+
limit: { type: "integer", minimum: 1, maximum: 20 },
|
|
292
|
+
},
|
|
293
|
+
required: ["query"],
|
|
294
|
+
additionalProperties: false,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
249
297
|
{
|
|
250
298
|
name: "relay_spawn_agent",
|
|
251
299
|
description: "Spawn a long-living provider agent through Relay's orchestrator, optionally handing it its first task via `prompt` in the same call. Defaults to your own host (override with orchestratorId) and returns the resolved agent id once it registers. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
|
|
@@ -506,6 +554,8 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
506
554
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
507
555
|
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
508
556
|
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
557
|
+
else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
|
|
558
|
+
else if (name === "relay_recall") result = relayRecall(auth, args);
|
|
509
559
|
else if (name === "relay_spawn_agent") result = await relaySpawnAgent(auth, args);
|
|
510
560
|
else if (name === "relay_spawn_targets") result = relaySpawnTargets(auth);
|
|
511
561
|
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
@@ -557,6 +607,33 @@ function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
|
557
607
|
};
|
|
558
608
|
}
|
|
559
609
|
|
|
610
|
+
function relayCompactAndResume(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
611
|
+
const agentId = callerAgentId(auth);
|
|
612
|
+
if (!agentId) throw new McpAuthError("relay_compact_and_resume requires a single caller agent identity");
|
|
613
|
+
return compactAndResume({
|
|
614
|
+
agentId,
|
|
615
|
+
workingState: stringField(args.workingState, "workingState", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
616
|
+
ruledOut: args.ruledOut,
|
|
617
|
+
mode: optionalEnum(args.mode, "mode", ["clear-inject", "native"] as const),
|
|
618
|
+
objective: optionalString(args.objective, "objective", 64_000),
|
|
619
|
+
}) as unknown as Record<string, unknown>;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function relayRecall(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
623
|
+
const agentId = callerAgentId(auth);
|
|
624
|
+
if (!agentId) throw new McpAuthError("relay_recall requires a single caller agent identity");
|
|
625
|
+
const matches = searchContinuationArchives({
|
|
626
|
+
agentId,
|
|
627
|
+
query: stringField(args.query, "query", { required: true, max: 1000 }),
|
|
628
|
+
limit: optionalPositiveInt(args.limit, "limit"),
|
|
629
|
+
});
|
|
630
|
+
return {
|
|
631
|
+
query: stringField(args.query, "query", { required: true, max: 1000 }),
|
|
632
|
+
matches,
|
|
633
|
+
count: matches.length,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
560
637
|
// MCP transport adapter over the shared send service (epic #342). The service owns from-resolution
|
|
561
638
|
// (token identity via ctx.callerAgentId), reply routing, target resolution + the converged
|
|
562
639
|
// store-ahead policy, authorization, persistence, and emit. This adapter only builds the input,
|
package/src/routes/commands.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { ServiceAuthError } from "../services/errors";
|
|
|
15
15
|
import { notifyBranchLanded } from "../branch-landed";
|
|
16
16
|
import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
|
|
17
17
|
import { handleReviewerMergeResult } from "../reviewer-pipeline";
|
|
18
|
+
import { enqueueContinuationInjectionAfterClear } from "../self-resume";
|
|
18
19
|
import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
|
|
19
20
|
|
|
20
21
|
const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
|
|
@@ -173,6 +174,7 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
173
174
|
// hook so a command that landed in the wrong context gets flagged.
|
|
174
175
|
getCompactionWatch().noteCommandSucceeded(command.type, command.id, command.target);
|
|
175
176
|
}
|
|
177
|
+
enqueueContinuationInjectionAfterClear(command);
|
|
176
178
|
if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
|
|
177
179
|
const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
|
|
178
180
|
if (workspaceId) {
|
|
@@ -285,6 +287,28 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
285
287
|
}
|
|
286
288
|
}
|
|
287
289
|
}
|
|
290
|
+
if (command.type === "workspace.pr-merge") {
|
|
291
|
+
const resultWorkspaceId = isRecord(command.result) ? cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 }) : undefined;
|
|
292
|
+
const workspaceId = resultWorkspaceId ?? command.correlationId;
|
|
293
|
+
if (workspaceId && command.status === "succeeded" && isRecord(command.result) && command.result.relayMerged === true) {
|
|
294
|
+
patchWorkspaceMetadata(workspaceId, {
|
|
295
|
+
relayMerged: true,
|
|
296
|
+
relayMergeCommandId: command.id,
|
|
297
|
+
relayMergedAt: Date.now(),
|
|
298
|
+
relayMergeResult: command.result,
|
|
299
|
+
relayMergeError: undefined,
|
|
300
|
+
relayMergeFailedAt: undefined,
|
|
301
|
+
});
|
|
302
|
+
} else if (workspaceId && (command.status === "failed" || command.status === "timed_out" || command.status === "rejected" || command.status === "canceled")) {
|
|
303
|
+
patchWorkspaceMetadata(workspaceId, {
|
|
304
|
+
relayMerged: false,
|
|
305
|
+
relayMergeCommandId: undefined,
|
|
306
|
+
relayMergeError: command.error ?? "PR merge failed",
|
|
307
|
+
relayMergeFailedAt: Date.now(),
|
|
308
|
+
relayMergeResult: command.result,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
288
312
|
if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
|
|
289
313
|
// Record the outcome on the row without touching status (#51) — observability
|
|
290
314
|
// for the dashboard; the CLI reads the result straight off the command.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Auto-split from routes.ts (#299). Domain: continuation archives.
|
|
2
|
+
import { isRecord } from "agent-relay-sdk";
|
|
3
|
+
import { resolveCallerAgentId } from "../agent-ref";
|
|
4
|
+
import { archiveContinuationSegment, appendContinuationArchiveRefs, getAgent, listAgents, ValidationError } from "../db";
|
|
5
|
+
import { getComponentAuth } from "../security";
|
|
6
|
+
import { cleanEpoch, cleanPositiveId, cleanString } from "../validation";
|
|
7
|
+
import { error, json, parseBody, type Handler } from "./_shared";
|
|
8
|
+
|
|
9
|
+
export const postContinuationArchiveRoute: Handler = async (req) => {
|
|
10
|
+
const parsed = await parseBody<unknown>(req);
|
|
11
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
12
|
+
if (!isRecord(parsed.body)) return error("JSON object body required", 400);
|
|
13
|
+
try {
|
|
14
|
+
const agentId = cleanString(parsed.body.agentId, "agentId", { required: true, max: 240 })!;
|
|
15
|
+
const denied = authorizeArchiveWriter(req, agentId);
|
|
16
|
+
if (denied) return denied;
|
|
17
|
+
if (!getAgent(agentId)) return error("agent not found", 404);
|
|
18
|
+
const archive = archiveContinuationSegment({
|
|
19
|
+
agentId,
|
|
20
|
+
segment: cleanString(parsed.body.segment, "segment", { required: true, max: 64_000 })!,
|
|
21
|
+
generation: cleanPositiveId(parsed.body.generation, "generation"),
|
|
22
|
+
now: cleanEpoch(parsed.body.occurredAt, "occurredAt"),
|
|
23
|
+
});
|
|
24
|
+
const envelope = appendContinuationArchiveRefs(agentId, [archive.ref]);
|
|
25
|
+
return json({ archive, envelope }, 201);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
28
|
+
throw e;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function authorizeArchiveWriter(req: Request, agentId: string): Response | null {
|
|
33
|
+
const component = getComponentAuth(req);
|
|
34
|
+
if (!component) return null;
|
|
35
|
+
const caller = resolveCallerAgentId(component.constraints, listAgents);
|
|
36
|
+
return caller === agentId ? null : error("forbidden", 403);
|
|
37
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { getApiDocs, getApiSpec } from "./spec";
|
|
|
20
20
|
import { getChannelBindings, getChannels, getIntegrations, postChannelActivity, postChannelBinding, postChannelEvent, postIntegrationEvent, putIntegrationRegistryRoute } from "./integrations";
|
|
21
21
|
import { getCommandById, getCommands, patchCommand, postCommand } from "./commands";
|
|
22
22
|
import { getConnectorById, getConnectorConfig, getConnectors, postConnectorAction, postConnectorCall, postConnectorRegister, putConnectorConfig } from "./connectors";
|
|
23
|
+
import { postContinuationArchiveRoute } from "./continuation-archives";
|
|
23
24
|
import { getEvents } from "./sse";
|
|
24
25
|
import { getHostDirectories, postAgent, postAgentSpawn, postRouteAdvice } from "./agents-spawn";
|
|
25
26
|
import { getInsightsConfigRoute, getInsightsObservationsRoute, postInsightsObservationRoute, putInsightsConfigRoute } from "./insights";
|
|
@@ -170,6 +171,7 @@ const routes: Route[] = [
|
|
|
170
171
|
route("PUT", "/api/insights/config", putInsightsConfigRoute),
|
|
171
172
|
route("GET", "/api/insights/observations", getInsightsObservationsRoute),
|
|
172
173
|
route("POST", "/api/insights/observations", postInsightsObservationRoute),
|
|
174
|
+
route("POST", "/api/continuation-archives", postContinuationArchiveRoute),
|
|
173
175
|
route("GET", "/api/config/:namespace", getConfigNamespace),
|
|
174
176
|
route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
|
|
175
177
|
route("GET", "/api/config/:namespace/:key", getConfigKey),
|
package/src/security.ts
CHANGED
|
@@ -207,6 +207,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
207
207
|
// Insights config (the feature toggle) stays admin-only via the default; only the
|
|
208
208
|
// mechanical observation feed is writable by lower-privilege callers.
|
|
209
209
|
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
210
|
+
if (pathname === "/api/continuation-archives") return "insights:write";
|
|
210
211
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
211
212
|
return null;
|
|
212
213
|
}
|
|
@@ -281,6 +282,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
281
282
|
// below and every observation was 403-dropped — the whole Insights feed stayed empty.
|
|
282
283
|
// Config (the feature toggle) intentionally keeps falling through to system:admin.
|
|
283
284
|
if (pathname === "/api/insights/observations") return method === "GET" ? "insights:read" : "insights:write";
|
|
285
|
+
if (pathname === "/api/continuation-archives") return "insights:write";
|
|
284
286
|
if (pathname.startsWith("/api/system/")) return "system:admin";
|
|
285
287
|
return "system:admin";
|
|
286
288
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { ValidationError } from "./db/connection.ts";
|
|
3
|
+
import type { SelfResumeConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SELF_RESUME_CONFIG: SelfResumeConfig = {
|
|
6
|
+
defaultMode: "clear-inject",
|
|
7
|
+
budget: {
|
|
8
|
+
maxGenerations: 8,
|
|
9
|
+
maxWallClockMs: 24 * 60 * 60 * 1000,
|
|
10
|
+
maxTokens: 2_000_000,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const VALID_SELF_RESUME_MODES = ["clear-inject", "native"] as const;
|
|
15
|
+
|
|
16
|
+
export function validateSelfResumeConfig(value: unknown): SelfResumeConfig {
|
|
17
|
+
if (!isRecord(value)) throw new ValidationError("self-resume config value must be an object");
|
|
18
|
+
const budget = isRecord(value.budget) ? value.budget : {};
|
|
19
|
+
return {
|
|
20
|
+
defaultMode: value.defaultMode === undefined || value.defaultMode === null
|
|
21
|
+
? DEFAULT_SELF_RESUME_CONFIG.defaultMode
|
|
22
|
+
: cleanMode(value.defaultMode, "defaultMode"),
|
|
23
|
+
budget: {
|
|
24
|
+
maxGenerations: cleanPositiveInt(budget.maxGenerations, "budget.maxGenerations", DEFAULT_SELF_RESUME_CONFIG.budget.maxGenerations),
|
|
25
|
+
maxWallClockMs: cleanPositiveInt(budget.maxWallClockMs, "budget.maxWallClockMs", DEFAULT_SELF_RESUME_CONFIG.budget.maxWallClockMs),
|
|
26
|
+
maxTokens: cleanPositiveInt(budget.maxTokens, "budget.maxTokens", DEFAULT_SELF_RESUME_CONFIG.budget.maxTokens),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanMode(value: unknown, field: string): SelfResumeConfig["defaultMode"] {
|
|
32
|
+
if (typeof value !== "string" || !VALID_SELF_RESUME_MODES.includes(value as any)) {
|
|
33
|
+
throw new ValidationError(`${field} must be one of: ${VALID_SELF_RESUME_MODES.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
return value as SelfResumeConfig["defaultMode"];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanPositiveInt(value: unknown, field: string, fallback: number): number {
|
|
39
|
+
if (value === undefined || value === null) return fallback;
|
|
40
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
|
|
41
|
+
throw new ValidationError(`${field} must be a positive integer`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { createCommand, listCommands } from "./commands-db";
|
|
3
|
+
import { emitCommandEvent } from "./command-events";
|
|
4
|
+
import { getSelfResumeConfig } from "./config-store";
|
|
5
|
+
import {
|
|
6
|
+
archiveContinuationSegment,
|
|
7
|
+
ensureContinuationEnvelope,
|
|
8
|
+
getAgent,
|
|
9
|
+
getContinuationEnvelope,
|
|
10
|
+
updateContinuationAfterCompact,
|
|
11
|
+
ValidationError,
|
|
12
|
+
} from "./db";
|
|
13
|
+
import { emitRelayEvent } from "./events";
|
|
14
|
+
import { notifySystemMessage } from "./notify";
|
|
15
|
+
import type { AgentCard, Command, ContinuationEnvelope, ContinuationLedgerEntry, SelfResumeMode } from "./types";
|
|
16
|
+
|
|
17
|
+
export interface CompactAndResumeInput {
|
|
18
|
+
agentId: string;
|
|
19
|
+
workingState: string;
|
|
20
|
+
ruledOut?: unknown;
|
|
21
|
+
mode?: SelfResumeMode;
|
|
22
|
+
objective?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CompactAndResumeResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
mode: SelfResumeMode;
|
|
28
|
+
envelope: ContinuationEnvelope;
|
|
29
|
+
continuation?: string;
|
|
30
|
+
command: Command;
|
|
31
|
+
exhausted?: boolean;
|
|
32
|
+
reason?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const VALID_RESUME_MODES = ["clear-inject", "native"] as const;
|
|
36
|
+
|
|
37
|
+
export function compactAndResume(input: CompactAndResumeInput): CompactAndResumeResult {
|
|
38
|
+
const agent = getAgent(input.agentId);
|
|
39
|
+
if (!agent) throw new ValidationError("agent not found");
|
|
40
|
+
const config = getSelfResumeConfig();
|
|
41
|
+
const mode = input.mode ?? config.defaultMode;
|
|
42
|
+
if (!VALID_RESUME_MODES.includes(mode)) throw new ValidationError(`mode must be one of: ${VALID_RESUME_MODES.join(", ")}`);
|
|
43
|
+
assertResumeSupported(agent, mode);
|
|
44
|
+
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const envelope = ensureContinuationEnvelope({
|
|
47
|
+
agentId: agent.id,
|
|
48
|
+
objective: input.objective,
|
|
49
|
+
config,
|
|
50
|
+
now,
|
|
51
|
+
});
|
|
52
|
+
const exhausted = budgetExhaustion(envelope, now);
|
|
53
|
+
if (exhausted) {
|
|
54
|
+
const command = createCleanStop(agent, exhausted);
|
|
55
|
+
notifySelfResumeBudgetExhausted(agent, envelope, exhausted);
|
|
56
|
+
return { ok: false, mode, envelope, command, exhausted: true, reason: exhausted };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const workingState = input.workingState.trim();
|
|
60
|
+
if (!workingState) throw new ValidationError("workingState required");
|
|
61
|
+
const generation = envelope.generation + 1;
|
|
62
|
+
const ledgerEntries = parseRuledOut(input.ruledOut, generation, now);
|
|
63
|
+
const archive = archiveContinuationSegment({
|
|
64
|
+
agentId: agent.id,
|
|
65
|
+
generation,
|
|
66
|
+
segment: workingState,
|
|
67
|
+
now,
|
|
68
|
+
});
|
|
69
|
+
const updated = updateContinuationAfterCompact({
|
|
70
|
+
agentId: agent.id,
|
|
71
|
+
generation,
|
|
72
|
+
ledgerEntries,
|
|
73
|
+
workingState,
|
|
74
|
+
archiveRefs: [archive.ref],
|
|
75
|
+
spentTokens: typeof agent.context?.tokensUsed === "number" ? agent.context.tokensUsed : 0,
|
|
76
|
+
now,
|
|
77
|
+
});
|
|
78
|
+
const continuation = renderContinuation(updated);
|
|
79
|
+
const command = mode === "native"
|
|
80
|
+
? createNativeCompact(agent.id, generation, continuation)
|
|
81
|
+
: createClearThenInject(agent.id, generation, continuation);
|
|
82
|
+
emitCommandEvent(command, "command.requested");
|
|
83
|
+
return { ok: true, mode, envelope: updated, continuation, command };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function enqueueContinuationInjectionAfterClear(command: Command): Command | null {
|
|
87
|
+
if (command.type !== "agent.clearContext" || command.status !== "succeeded") return null;
|
|
88
|
+
const resume = isRecord(command.params.selfResume) ? command.params.selfResume : undefined;
|
|
89
|
+
const continuation = typeof resume?.continuation === "string" ? resume.continuation : undefined;
|
|
90
|
+
if (!continuation) return null;
|
|
91
|
+
const generation = typeof resume?.generation === "number" ? resume.generation : undefined;
|
|
92
|
+
const existing = listCommands({ target: command.target, type: "agent.injectContext", limit: 500 })
|
|
93
|
+
.find((item) => item.correlationId === command.id);
|
|
94
|
+
if (existing) return null;
|
|
95
|
+
const inject = createCommand({
|
|
96
|
+
type: "agent.injectContext",
|
|
97
|
+
source: "system",
|
|
98
|
+
target: command.target,
|
|
99
|
+
correlationId: command.id,
|
|
100
|
+
params: {
|
|
101
|
+
reason: "self-resume-continuation",
|
|
102
|
+
content: continuation,
|
|
103
|
+
selfResume: {
|
|
104
|
+
generation,
|
|
105
|
+
afterClearCommandId: command.id,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
emitCommandEvent(inject, "command.requested");
|
|
110
|
+
return inject;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function renderContinuation(envelope: ContinuationEnvelope): string {
|
|
114
|
+
const ledger = envelope.ledger.length
|
|
115
|
+
? envelope.ledger.map((entry) => `- gen ${entry.gen} @ ${new Date(entry.at).toISOString()}: ${entry.ruledOut}${entry.why ? `; why: ${entry.why}` : ""}`).join("\n")
|
|
116
|
+
: "- none";
|
|
117
|
+
return [
|
|
118
|
+
`You have been resumed by Agent Relay — generation ${envelope.generation}.`,
|
|
119
|
+
"",
|
|
120
|
+
"Objective (verbatim, relay-owned):",
|
|
121
|
+
envelope.objective,
|
|
122
|
+
"",
|
|
123
|
+
"Ruled-out ledger (append-only):",
|
|
124
|
+
ledger,
|
|
125
|
+
"",
|
|
126
|
+
"Working state:",
|
|
127
|
+
envelope.workingState,
|
|
128
|
+
"",
|
|
129
|
+
"Archive refs:",
|
|
130
|
+
envelope.archiveRefs.length ? envelope.archiveRefs.join("\n") : "- none",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertResumeSupported(agent: AgentCard, mode: SelfResumeMode): void {
|
|
135
|
+
const context = agent.providerCapabilities?.context;
|
|
136
|
+
const resume = context?.resume ?? (context?.clear && context?.inject ? "clear-inject" : "none");
|
|
137
|
+
if (resume === "none") throw new ValidationError("self-resume unsupported, fresh-session handoff only");
|
|
138
|
+
if (mode === "native" && resume !== "native") throw new ValidationError("native self-resume unsupported for this provider");
|
|
139
|
+
if (mode === "clear-inject" && !(context?.clear && context?.inject)) {
|
|
140
|
+
throw new ValidationError("clear-inject self-resume unsupported for this provider");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function budgetExhaustion(envelope: ContinuationEnvelope, now: number): string | null {
|
|
145
|
+
if (envelope.generation >= envelope.budget.maxGenerations) return "max generations exhausted";
|
|
146
|
+
if (Math.max(0, now - envelope.createdAt) >= envelope.budget.maxWallClockMs) return "max wall clock exhausted";
|
|
147
|
+
if (envelope.budget.spent.tokens >= envelope.budget.maxTokens) return "max token budget exhausted";
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createNativeCompact(agentId: string, generation: number, continuation: string): Command {
|
|
152
|
+
return createCommand({
|
|
153
|
+
type: "agent.compact",
|
|
154
|
+
source: "system",
|
|
155
|
+
target: agentId,
|
|
156
|
+
params: {
|
|
157
|
+
reason: "self-resume",
|
|
158
|
+
instructions: continuation,
|
|
159
|
+
selfResume: { generation, mode: "native" },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function createClearThenInject(agentId: string, generation: number, continuation: string): Command {
|
|
165
|
+
return createCommand({
|
|
166
|
+
type: "agent.clearContext",
|
|
167
|
+
source: "system",
|
|
168
|
+
target: agentId,
|
|
169
|
+
params: {
|
|
170
|
+
reason: "self-resume",
|
|
171
|
+
selfResume: { generation, mode: "clear-inject", continuation },
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createCleanStop(agent: AgentCard, reason: string): Command {
|
|
177
|
+
const command = createCommand({
|
|
178
|
+
type: "agent.shutdown",
|
|
179
|
+
source: "system",
|
|
180
|
+
target: agent.id,
|
|
181
|
+
params: {
|
|
182
|
+
reason: `self-resume budget exhausted: ${reason}`,
|
|
183
|
+
graceful: true,
|
|
184
|
+
timeoutMs: 10_000,
|
|
185
|
+
preserveRegistration: true,
|
|
186
|
+
selfResume: { exhausted: true },
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
emitCommandEvent(command, "command.requested");
|
|
190
|
+
return command;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function notifySelfResumeBudgetExhausted(agent: AgentCard, envelope: ContinuationEnvelope, reason: string): void {
|
|
194
|
+
const payload = {
|
|
195
|
+
kind: "agent.resume_budget_exhausted",
|
|
196
|
+
agentId: agent.id,
|
|
197
|
+
parent: agent.spawnedBy,
|
|
198
|
+
reason,
|
|
199
|
+
generation: envelope.generation,
|
|
200
|
+
};
|
|
201
|
+
emitRelayEvent({ type: "agent.resume_budget_exhausted", source: "server", subject: agent.id, data: payload });
|
|
202
|
+
if (!agent.spawnedBy) return;
|
|
203
|
+
notifySystemMessage(agent.spawnedBy, {
|
|
204
|
+
subject: "Self-resume budget exhausted",
|
|
205
|
+
body: `Spawned agent \`${agent.id}\` stopped cleanly: ${reason}.`,
|
|
206
|
+
payload,
|
|
207
|
+
replyExpected: false,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseRuledOut(value: unknown, generation: number, now: number): ContinuationLedgerEntry[] {
|
|
212
|
+
if (value === undefined || value === null) return [];
|
|
213
|
+
if (!Array.isArray(value)) throw new ValidationError("ruledOut must be an array");
|
|
214
|
+
return value.map((item, index) => {
|
|
215
|
+
if (typeof item === "string") return ledgerEntry(item, "", generation, now);
|
|
216
|
+
if (!isRecord(item)) throw new ValidationError(`ruledOut[${index}] must be a string or object`);
|
|
217
|
+
const ruledOut = typeof item.ruledOut === "string" ? item.ruledOut.trim() : "";
|
|
218
|
+
const why = typeof item.why === "string" ? item.why.trim() : "";
|
|
219
|
+
if (!ruledOut) throw new ValidationError(`ruledOut[${index}].ruledOut required`);
|
|
220
|
+
return ledgerEntry(ruledOut, why, generation, now);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function ledgerEntry(ruledOut: string, why: string, generation: number, now: number): ContinuationLedgerEntry {
|
|
225
|
+
const cleaned = ruledOut.trim();
|
|
226
|
+
if (!cleaned) throw new ValidationError("ruledOut entries cannot be blank");
|
|
227
|
+
return { gen: generation, ruledOut: cleaned, why, at: now };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function continuationForAgent(agentId: string): ContinuationEnvelope | null {
|
|
231
|
+
return getContinuationEnvelope(agentId);
|
|
232
|
+
}
|
|
@@ -172,6 +172,10 @@ export function sendMessageService(
|
|
|
172
172
|
} else {
|
|
173
173
|
emitNewMessage(result.message);
|
|
174
174
|
}
|
|
175
|
+
} else if (result.updated) {
|
|
176
|
+
// Session-step upsert (#parity): the row changed in place (same DB id). Re-emit so the
|
|
177
|
+
// dashboard's id-keyed merge replaces the existing step live — no duplicate row.
|
|
178
|
+
emitNewMessage(result.message);
|
|
175
179
|
}
|
|
176
180
|
return { message: result.message, created: result.created, receipt };
|
|
177
181
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { createCommand, getCommand } from "./commands-db";
|
|
3
|
+
import { patchWorkspaceMetadata } from "./db";
|
|
4
|
+
import { handleReviewerPipelineScan, isAwaitingReviewerApproval } from "./reviewer-pipeline";
|
|
5
|
+
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
6
|
+
|
|
7
|
+
const ACTIVE_COMMAND_STATUSES = new Set(["pending", "accepted", "running"]);
|
|
8
|
+
const PASSING_CONCLUSIONS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
9
|
+
const PASSING_STATES = new Set(["SUCCESS"]);
|
|
10
|
+
const READY_MERGE_STATES = new Set(["CLEAN", "HAS_HOOKS"]);
|
|
11
|
+
|
|
12
|
+
function relayPrMergeAlreadyHandled(workspace: WorkspaceRecord): boolean {
|
|
13
|
+
const commandId = text(workspace.metadata.relayMergeCommandId);
|
|
14
|
+
if (commandId) {
|
|
15
|
+
const command = getCommand(commandId);
|
|
16
|
+
if (command && ACTIVE_COMMAND_STATUSES.has(command.status)) return true;
|
|
17
|
+
if (command?.status === "succeeded" && command.result?.relayMerged === true) return true;
|
|
18
|
+
}
|
|
19
|
+
return workspace.metadata.relayMerged === true && !commandId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function nativeAutoMergeArmed(workspace: WorkspaceRecord): boolean {
|
|
23
|
+
if (isRecord(workspace.metadata.mergeResult) && workspace.metadata.mergeResult.autoMergeArmed === true) return true;
|
|
24
|
+
const commandId = text(workspace.metadata.autoMergeArmCommandId);
|
|
25
|
+
if (!commandId) return false;
|
|
26
|
+
const command = getCommand(commandId);
|
|
27
|
+
return command?.status === "succeeded" && command.result?.autoMergeArmed === true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function prAutoMergePolicy(workspace: WorkspaceRecord, preview: WorkspaceMergePreview): WorkspaceAutoMergePolicy {
|
|
31
|
+
const commandId = text(workspace.metadata.mergeCommandId);
|
|
32
|
+
if (commandId) {
|
|
33
|
+
const command = getCommand(commandId);
|
|
34
|
+
const autoMerge = text(command?.params.autoMerge);
|
|
35
|
+
if (autoMerge === "on-green" || autoMerge === "on-approval" || autoMerge === "manual") return autoMerge;
|
|
36
|
+
}
|
|
37
|
+
if (workspace.metadata.awaitingApproval === true || (isRecord(workspace.metadata.mergeResult) && workspace.metadata.mergeResult.awaitingApproval === true)) return "on-approval";
|
|
38
|
+
return "on-green";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function prReadyForRelayMerge(workspace: WorkspaceRecord, preview: WorkspaceMergePreview): { ok: true; policy: WorkspaceAutoMergePolicy } | { ok: false; reason: string; policy?: WorkspaceAutoMergePolicy } {
|
|
42
|
+
if (preview.strategy !== "pr") return { ok: false, reason: "not-pr-strategy" };
|
|
43
|
+
if (preview.prMerged === true || preview.prState === "merged") return { ok: false, reason: "already-merged" };
|
|
44
|
+
if (!preview.prNumber && !preview.prUrl) return { ok: false, reason: "missing-pr" };
|
|
45
|
+
if (nativeAutoMergeArmed(workspace)) return { ok: false, reason: "native-auto-merge-armed" };
|
|
46
|
+
if (relayPrMergeAlreadyHandled(workspace)) return { ok: false, reason: "relay-merge-already-dispatched" };
|
|
47
|
+
|
|
48
|
+
const policy = prAutoMergePolicy(workspace, preview);
|
|
49
|
+
if (policy === "manual") return { ok: false, reason: "manual", policy };
|
|
50
|
+
if (policy === "on-approval" && preview.reviewDecision !== "APPROVED") return { ok: false, reason: "approval-required", policy };
|
|
51
|
+
if (!prMergeable(preview)) return { ok: false, reason: "not-mergeable", policy };
|
|
52
|
+
if (!statusChecksGreen(preview.statusCheckRollup)) return { ok: false, reason: "checks-not-green", policy };
|
|
53
|
+
return { ok: true, policy };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function preparePrCompletionScan(workspace: WorkspaceRecord, preview: WorkspaceMergePreview, orchestratorAgentId: string): {
|
|
57
|
+
armCommand?: Command;
|
|
58
|
+
notifiedOwner?: string;
|
|
59
|
+
mergeCommand?: Command;
|
|
60
|
+
waitReason?: string;
|
|
61
|
+
stop?: boolean;
|
|
62
|
+
} {
|
|
63
|
+
if (isAwaitingReviewerApproval(workspace)) {
|
|
64
|
+
const action = handleReviewerPipelineScan(workspace, preview, orchestratorAgentId);
|
|
65
|
+
if (action.armCommand) return { armCommand: action.armCommand, stop: true };
|
|
66
|
+
if (action.notifiedOwner) return { notifiedOwner: action.notifiedOwner, stop: true };
|
|
67
|
+
}
|
|
68
|
+
const relayPr = prReadyForRelayMerge(workspace, preview);
|
|
69
|
+
if (relayPr.ok) return { mergeCommand: dispatchRelayPrMergeCommand(workspace, preview, orchestratorAgentId, relayPr.policy), stop: true };
|
|
70
|
+
return {
|
|
71
|
+
waitReason: preview.strategy === "pr" && relayPr.reason !== "not-pr-strategy" ? relayPr.reason : undefined,
|
|
72
|
+
stop: isAwaitingReviewerApproval(workspace),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function dispatchRelayPrMergeCommand(workspace: WorkspaceRecord, preview: WorkspaceMergePreview, orchestratorAgentId: string, policy: WorkspaceAutoMergePolicy): Command {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const prNumber = preview.prNumber ?? (typeof workspace.metadata.prNumber === "number" ? workspace.metadata.prNumber : undefined);
|
|
79
|
+
const prUrl = preview.prUrl ?? text(workspace.metadata.prUrl);
|
|
80
|
+
const command = createCommand({
|
|
81
|
+
type: "workspace.pr-merge",
|
|
82
|
+
source: "system",
|
|
83
|
+
target: orchestratorAgentId,
|
|
84
|
+
correlationId: workspace.id,
|
|
85
|
+
params: {
|
|
86
|
+
action: "pr-merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath,
|
|
87
|
+
branch: workspace.branch, baseRef: workspace.baseRef, prNumber, prUrl, policy,
|
|
88
|
+
requestedBy: "relay-pr-completion", requestedAt: now,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
patchWorkspaceMetadata(workspace.id, {
|
|
92
|
+
relayMerged: true, relayMergeCommandId: command.id, relayMergeDispatchedAt: now, relayMergePolicy: policy,
|
|
93
|
+
relayMergeError: undefined, relayMergeFailedAt: undefined,
|
|
94
|
+
});
|
|
95
|
+
return command;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function statusChecksGreen(rollup: unknown): boolean {
|
|
99
|
+
if (!Array.isArray(rollup) || rollup.length === 0) return false;
|
|
100
|
+
return rollup.every(statusNodeGreen);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function prMergeable(preview: WorkspaceMergePreview): boolean {
|
|
104
|
+
const mergeable = text(preview.mergeable)?.toUpperCase();
|
|
105
|
+
if (mergeable !== "MERGEABLE") return false;
|
|
106
|
+
const state = text(preview.mergeStateStatus)?.toUpperCase();
|
|
107
|
+
return state ? READY_MERGE_STATES.has(state) : true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusNodeGreen(node: unknown): boolean {
|
|
111
|
+
if (!isRecord(node)) return false;
|
|
112
|
+
const conclusion = text(node.conclusion)?.toUpperCase();
|
|
113
|
+
if (conclusion) return PASSING_CONCLUSIONS.has(conclusion);
|
|
114
|
+
const state = text(node.state)?.toUpperCase();
|
|
115
|
+
if (state) return PASSING_STATES.has(state);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function text(value: unknown): string | undefined {
|
|
120
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
121
|
+
}
|