@inflexa-ai/harness 0.4.4 → 0.5.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.
@@ -2,9 +2,10 @@
2
2
  * Cortex chat data parts — custom parts emitted via writer.write() / writer.custom()
3
3
  * that flow through the chat stream and are rendered by the frontend.
4
4
  *
5
- * 15 part types across four categories:
5
+ * Categories:
6
6
  * Presentation: data-presentation (agent-synthesized content),
7
7
  * data-plan (plan by id), data-file-reference (files by path)
8
+ * Approval: data-ask (a tool's pending user-approval prompt)
8
9
  * Run lifecycle: run-started, dag-state, run-completed, run-failed, run-synthesis,
9
10
  * synthesis-progress
10
11
  * Per-step live: step-activity, step-file-tree
@@ -80,6 +81,23 @@ export interface FileReferencePart {
80
81
  title?: string;
81
82
  files: FileReferenceEntry[];
82
83
  }
84
+ /**
85
+ * A tool's pending request for an explicit user decision. Re-emitted under the
86
+ * same `id` when the ask reaches a terminal status, so reconciling readers fold
87
+ * pending → outcome latest-wins.
88
+ */
89
+ export interface AskPart {
90
+ type: "data-ask";
91
+ /** The ask's ledger uuidv7 id — what a surface passes back to `answer`. */
92
+ id: string;
93
+ /** Human-facing headline for the pending approval. */
94
+ title: string;
95
+ /** The exact command or operation being approved — what the user sees granted. */
96
+ command: string;
97
+ /** Optional additional detail a surface may render. */
98
+ detail?: string;
99
+ status: "pending" | "resolved" | "rejected" | "aborted" | "expired";
100
+ }
83
101
  export interface RunStartedPart {
84
102
  type: "data-run-started";
85
103
  runId: string;
@@ -303,4 +321,4 @@ export interface DataPreviewFailedPart {
303
321
  /** Optional taxonomy of failure kinds. */
304
322
  errorKind?: "render" | "submit" | "build" | "timeout" | "internal";
305
323
  }
306
- export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart;
324
+ export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart;
@@ -2,9 +2,10 @@
2
2
  * Cortex chat data parts — custom parts emitted via writer.write() / writer.custom()
3
3
  * that flow through the chat stream and are rendered by the frontend.
4
4
  *
5
- * 15 part types across four categories:
5
+ * Categories:
6
6
  * Presentation: data-presentation (agent-synthesized content),
7
7
  * data-plan (plan by id), data-file-reference (files by path)
8
+ * Approval: data-ask (a tool's pending user-approval prompt)
8
9
  * Run lifecycle: run-started, dag-state, run-completed, run-failed, run-synthesis,
9
10
  * synthesis-progress
10
11
  * Per-step live: step-activity, step-file-tree
@@ -1,4 +1,4 @@
1
- export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, CortexChatPart, } from "./chat-parts.js";
1
+ export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, CortexChatPart, } from "./chat-parts.js";
2
2
  export type { EventSource, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, FinishEvent, ChatErrorEvent, CortexChatEvent } from "./chat-events.js";
3
3
  export type { TextPart, ToolCallPart, CortexPart, CortexMessage } from "./message.js";
4
4
  export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./part-registry.js";
@@ -3,6 +3,7 @@ export const PART_REGISTRY = {
3
3
  "data-plan": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
4
4
  "data-run-card": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
5
5
  "data-file-reference": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
6
+ "data-ask": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: true },
6
7
  "data-run-started": { emitter: "workflow", consumer: "sidebar", transient: true, reconciling: false },
7
8
  "data-dag-state": { emitter: "workflow", consumer: "sidebar", transient: false, reconciling: true },
8
9
  "data-step-activity": { emitter: "workflow", consumer: "sidebar", transient: true, reconciling: true },
@@ -103,6 +103,27 @@ export declare const FileReferencePartSchema: z.ZodObject<{
103
103
  caption: z.ZodOptional<z.ZodString>;
104
104
  }, z.core.$strip>>;
105
105
  }, z.core.$strip>;
106
+ export declare const AskStatusSchema: z.ZodEnum<{
107
+ pending: "pending";
108
+ aborted: "aborted";
109
+ resolved: "resolved";
110
+ rejected: "rejected";
111
+ expired: "expired";
112
+ }>;
113
+ export declare const AskPartSchema: z.ZodObject<{
114
+ type: z.ZodLiteral<"data-ask">;
115
+ id: z.ZodString;
116
+ title: z.ZodString;
117
+ command: z.ZodString;
118
+ detail: z.ZodOptional<z.ZodString>;
119
+ status: z.ZodEnum<{
120
+ pending: "pending";
121
+ aborted: "aborted";
122
+ resolved: "resolved";
123
+ rejected: "rejected";
124
+ expired: "expired";
125
+ }>;
126
+ }, z.core.$strip>;
106
127
  export declare const RunStartedPartSchema: z.ZodObject<{
107
128
  type: z.ZodLiteral<"data-run-started">;
108
129
  runId: z.ZodString;
@@ -478,6 +499,19 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
478
499
  runId: z.ZodOptional<z.ZodString>;
479
500
  caption: z.ZodOptional<z.ZodString>;
480
501
  }, z.core.$strip>>;
502
+ }, z.core.$strip>, z.ZodObject<{
503
+ type: z.ZodLiteral<"data-ask">;
504
+ id: z.ZodString;
505
+ title: z.ZodString;
506
+ command: z.ZodString;
507
+ detail: z.ZodOptional<z.ZodString>;
508
+ status: z.ZodEnum<{
509
+ pending: "pending";
510
+ aborted: "aborted";
511
+ resolved: "resolved";
512
+ rejected: "rejected";
513
+ expired: "expired";
514
+ }>;
481
515
  }, z.core.$strip>, z.ZodObject<{
482
516
  type: z.ZodLiteral<"data-run-started">;
483
517
  runId: z.ZodString;
@@ -78,6 +78,16 @@ export const FileReferencePartSchema = z.object({
78
78
  title: z.string().optional(),
79
79
  files: z.array(FileReferenceEntrySchema).min(1).max(10),
80
80
  });
81
+ // ── Ask (user approval) ─────────────────────────────────────────────
82
+ export const AskStatusSchema = z.enum(["pending", "resolved", "rejected", "aborted", "expired"]);
83
+ export const AskPartSchema = z.object({
84
+ type: z.literal("data-ask"),
85
+ id: z.string(),
86
+ title: z.string(),
87
+ command: z.string(),
88
+ detail: z.string().optional(),
89
+ status: AskStatusSchema,
90
+ });
81
91
  // ── Run Started ─────────────────────────────────────────────────────
82
92
  export const RunStartedPartSchema = z.object({
83
93
  type: z.literal("data-run-started"),
@@ -278,6 +288,7 @@ export const CortexChatPartSchema = z.discriminatedUnion("type", [
278
288
  PlanPartSchema,
279
289
  RunCardPartSchema,
280
290
  FileReferencePartSchema,
291
+ AskPartSchema,
281
292
  RunStartedPartSchema,
282
293
  DagStatePartSchema,
283
294
  StepActivityPartSchema,
package/dist/index.d.ts CHANGED
@@ -26,6 +26,10 @@ export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
26
26
  export type { RunLauncher, LaunchOptions, LaunchRunOptions, LaunchOutcome } from "./execution/run-launcher.js";
27
27
  export { defineTool, isToolError } from "./tools/define-tool.js";
28
28
  export type { Tool, ToolDefinition, ToolContext, ToolError } from "./tools/define-tool.js";
29
+ export { createAskGateway } from "./tools/approval/gateway.js";
30
+ export type { AskGateway, AskGatewayDeps, AskContext, AnswerOutcome, PendingAsk } from "./tools/approval/gateway.js";
31
+ export { UnavailableAsk, AskRejectedError } from "./tools/approval/contract.js";
32
+ export type { Ask, AskRequest, AskReply, AskApproval } from "./tools/approval/contract.js";
29
33
  export { runAgent, finalText } from "./loop/run-agent.js";
30
34
  export type { RunAgentOptions, RunAgentResult, AgentFinish } from "./loop/run-agent.js";
31
35
  export type { AgentDefinition, RunStep, EmitFn, EmitEvent, EventSource } from "./loop/types.js";
@@ -103,7 +107,7 @@ export { queryStepsByRun } from "./state/step-executions.js";
103
107
  export type { CortexRunRow, StepExecutionRow, RunStatus } from "./state/schema.js";
104
108
  export type { DbError } from "./lib/db-result.js";
105
109
  export { REFERENCE_DATA_CATALOG, REFERENCE_DATA_CATALOG_VERSION, ReferenceArtifactPathSchema, ReferenceArtifactSchema, ReferenceDataCatalogSchema, ReferenceDatasetSchema, ReferenceSha256Schema, UnknownReferenceDatasetError, referenceArtifactKey, resolveReferenceInstallPlan, } from "./reference-data/catalog.js";
106
- export type { ReferenceArtifact, ReferenceDataCatalog, ReferenceDataset, ReferenceInstallPlan, ReferenceInstallPlanDataset, } from "./reference-data/catalog.js";
110
+ export type { ReferenceArtifact, ReferenceDataCatalog, ReferenceDataset, ReferenceInstallPlan, ReferenceInstallPlanDataset } from "./reference-data/catalog.js";
107
111
  export { REFERENCE_INSTALL_RECEIPT_VERSION, ReferenceInstallReceiptSchema, parseReferenceInstallReceipt } from "./reference-data/receipt.js";
108
112
  export type { ReferenceInstallReceipt, ReferenceReceiptArtifact } from "./reference-data/receipt.js";
109
113
  export { queryActiveSandboxes } from "./state/active-sandboxes.js";
package/dist/index.js CHANGED
@@ -25,6 +25,13 @@ export { UnavailablePreviewPublisher } from "./tools/report/preview-publisher.js
25
25
  export { createDbosRunLauncher } from "./execution/dbos-run-launcher.js";
26
26
  // Tool primitive.
27
27
  export { defineTool, isToolError } from "./tools/define-tool.js";
28
+ // Seam: tool approval (`ctx.ask`). `createAskGateway` realizes the `Ask` seam
29
+ // over the injected `Pool` and carries the outward `answer`/`pending` API plus
30
+ // the boot-time `sweepExpired`; `UnavailableAsk` is the shipped deny-by-default
31
+ // realization for an unwired or non-interactive host. The `AskRequest`/`AskReply`
32
+ // contract and the `AskRejectedError` denial throw come from the seam contract.
33
+ export { createAskGateway } from "./tools/approval/gateway.js";
34
+ export { UnavailableAsk, AskRejectedError } from "./tools/approval/contract.js";
28
35
  // Agent loop.
29
36
  export { runAgent, finalText } from "./loop/run-agent.js";
30
37
  // `passthroughStep` is the in-process `RunStep`: the host request/chat path runs
@@ -1,9 +1,10 @@
1
1
  import { type FinishReason } from "ai";
2
2
  import type { AgentSession } from "../auth/types.js";
3
3
  import type { AgentChat, PromptCachePolicy } from "../providers/types.js";
4
+ import { type AskApproval, type AskRequest } from "../tools/approval/contract.js";
4
5
  import type { AgentDefinition, EmitFn, LoopMessage, RunStep } from "./types.js";
5
6
  export interface AgentFinish {
6
- readonly reason: FinishReason | "max_iterations";
7
+ readonly reason: FinishReason | "max_iterations" | "denied";
7
8
  readonly cappedOut: boolean;
8
9
  readonly truncationRecoveries: number;
9
10
  }
@@ -20,6 +21,16 @@ export interface RunAgentOptions {
20
21
  readonly provider: AgentChat;
21
22
  readonly signal: AbortSignal;
22
23
  readonly emit: EmitFn;
24
+ /**
25
+ * Per-turn user-approval seam threaded into every tool's `ToolContext` as
26
+ * `ctx.ask`. A conversation tool calls it to pause for an explicit user
27
+ * decision; the caller (the turn) wires the realization that surfaces the
28
+ * prompt and returns the reply. Omitted on non-interactive paths (workflow
29
+ * contexts, headless embedders): approval then resolves to the shipped
30
+ * deny-by-default `UnavailableAsk`, so a tool that asks where nothing can
31
+ * answer is denied rather than left waiting on a surface that never responds.
32
+ */
33
+ readonly ask?: (request: AskRequest) => Promise<AskApproval>;
23
34
  readonly runStep: RunStep;
24
35
  readonly formatStepName?: StepNameFormatter;
25
36
  readonly isFatalLoopError?: (err: unknown) => boolean;
@@ -3,6 +3,7 @@ import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
3
3
  import { classifyProviderError } from "../providers/errors.js";
4
4
  import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
5
5
  import { resultStep } from "./run-step.js";
6
+ import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
6
7
  import { isToolError } from "../tools/define-tool.js";
7
8
  import { addChatUsage, recordAgentRun } from "./metrics.js";
8
9
  const TRUNCATED_PROSE_STEER = "Your previous reply was cut off at the output-token limit; continue concisely, or finish via your terminal tool.";
@@ -31,11 +32,18 @@ export async function runAgent(agent, initial, session, opts) {
31
32
  inputSchema: jsonSchema(t.jsonSchema),
32
33
  }),
33
34
  ]));
35
+ // Approval resolves once to the caller's seam, or the deny-by-default one
36
+ // when it wires none: a tool that pauses for a user decision where no
37
+ // interactive surface is present is denied rather than left waiting on an
38
+ // answer that cannot come.
39
+ const unavailableAsk = new UnavailableAsk();
40
+ const ask = opts.ask ?? ((request) => unavailableAsk.ask(request));
34
41
  const toolCtx = (tu) => ({
35
42
  session,
36
43
  signal,
37
44
  emit,
38
45
  runStep: (name, fn) => runStep(`${formatStepName.tool(tu.toolName, tu.toolCallId)}:${name}`, fn),
46
+ ask,
39
47
  });
40
48
  let iterations = 0;
41
49
  let truncationRecoveries = 0;
@@ -44,6 +52,16 @@ export async function runAgent(agent, initial, session, opts) {
44
52
  // byte-identical to be read back.
45
53
  const providerOptions = promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE);
46
54
  const usage = {};
55
+ // The user said no. A subsequent model call would only let the agent argue
56
+ // with the decision, or spend a call acknowledging it; the denial tool result
57
+ // is itself what the surface renders, so the turn ends the moment a denial
58
+ // lands in a dispatch round — after the concurrent siblings in that same round
59
+ // have completed and been appended. Mirrors the clean-stop terminal path.
60
+ const stopOnDenial = async (i) => {
61
+ await emit({ type: "iteration", source, index: i, final: true });
62
+ recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
63
+ return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries } };
64
+ };
47
65
  for (let i = 0; i < agent.maxIterations; i++) {
48
66
  iterations = i + 1;
49
67
  const request = {
@@ -81,6 +99,8 @@ export async function runAgent(agent, initial, session, opts) {
81
99
  }
82
100
  results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
83
101
  messages.push({ role: "tool", content: results });
102
+ if (hasDenial(results))
103
+ return stopOnDenial(i);
84
104
  continue;
85
105
  }
86
106
  if (reply.finishReason !== "tool-calls") {
@@ -104,6 +124,8 @@ export async function runAgent(agent, initial, session, opts) {
104
124
  });
105
125
  }
106
126
  messages.push({ role: "tool", content: results });
127
+ if (hasDenial(results))
128
+ return stopOnDenial(i);
107
129
  }
108
130
  // Cache defeater (known; not fixed here). Emptying the tool set changes the
109
131
  // very front of the request prefix — tool definitions are cached ahead of
@@ -176,6 +198,8 @@ async function execute(tu, tool, input, ctx, isFatalLoopError) {
176
198
  catch (err) {
177
199
  if (isFatalLoopError(err))
178
200
  throw err;
201
+ if (isAskRejected(err))
202
+ return deniedResult(tu, err.feedback);
179
203
  return errorResult(tu, toolErrorContent(err));
180
204
  }
181
205
  }
@@ -206,9 +230,33 @@ function errorResult(toolCall, content) {
206
230
  output: { type: "error-text", value: content },
207
231
  };
208
232
  }
233
+ function isAskRejected(err) {
234
+ // Name-based fallback recognizes a rejection thrown from a different module
235
+ // realm, where `instanceof` against this file's class reference would miss it.
236
+ return err instanceof AskRejectedError || (err instanceof Error && err.name === "AskRejectedError");
237
+ }
238
+ /**
239
+ * Map a rejected approval to a model-visible `execution-denied` tool result. The
240
+ * prose is the model's only account of the denial; `isErrorOutput` already
241
+ * treats `execution-denied` as an error result.
242
+ */
243
+ function deniedResult(toolCall, feedback) {
244
+ const reason = feedback === undefined || feedback.length === 0
245
+ ? "The user rejected this action."
246
+ : `The user rejected this action with the following feedback: ${feedback}`;
247
+ return {
248
+ type: "tool-result",
249
+ toolCallId: toolCall.toolCallId,
250
+ toolName: toolCall.toolName,
251
+ output: { type: "execution-denied", reason },
252
+ };
253
+ }
209
254
  function isErrorOutput(result) {
210
255
  return result.output.type === "error-text" || result.output.type === "error-json" || result.output.type === "execution-denied";
211
256
  }
257
+ function hasDenial(results) {
258
+ return results.some((r) => r.output.type === "execution-denied");
259
+ }
212
260
  export function finalText(messages) {
213
261
  const last = messages.at(-1);
214
262
  if (last === undefined || last.role !== "assistant")
@@ -44,7 +44,12 @@ export interface DockerClientConfig {
44
44
  resolveWorkspaceRoot: ResolveWorkspaceRoot;
45
45
  /** Host lib store; bind-mounted read-only at `/mnt/libs` when set. */
46
46
  libStorePath?: string;
47
- /** Host ref store; bind-mounted read-only at `/mnt/refs` when set. */
47
+ /**
48
+ * Host ref store; bind-mounted read-only at `/mnt/refs`. Embedders pass the
49
+ * configured store location unconditionally — existence is (re-)checked at each
50
+ * sandbox creation, so a store installed mid-session is mounted into subsequent
51
+ * sandboxes without a runtime restart.
52
+ */
48
53
  refStorePath?: string;
49
54
  /**
50
55
  * Force the container platform (e.g. `linux/amd64`). Set by hosts that mount
@@ -28,7 +28,7 @@
28
28
  * Either way the workload ends as uid 1000 with `no-new-privileges` and no
29
29
  * effective capabilities.
30
30
  */
31
- import { existsSync, statSync } from "node:fs";
31
+ import { existsSync, lstatSync, statSync } from "node:fs";
32
32
  import { join } from "node:path";
33
33
  import Docker from "dockerode";
34
34
  import { ResultAsync, err, ok } from "neverthrow";
@@ -80,6 +80,30 @@ function libStoreUsable(libStorePath) {
80
80
  }
81
81
  return existsSync(join(current, "packages.txt")) && existsSync(join(current, "meta.json"));
82
82
  }
83
+ /**
84
+ * Whether `refStorePath` is, right now, a real directory fit to be a bind authority.
85
+ * Deliberately shallow — it checks existence-as-a-directory only, never the store's
86
+ * interior: the harness knows nothing of the ref store's layout, and dataset
87
+ * completeness/receipts are the embedder's concern, so (unlike `libStoreUsable`, which
88
+ * validates harness-known `current` layout) there is nothing here to validate beyond
89
+ * "a directory is present."
90
+ *
91
+ * `lstatSync` (not `statSync`) does NOT follow symlinks, so a symlinked path is rejected
92
+ * even when it resolves to a directory: the bind authority must itself be the store
93
+ * directory, not an indirection that may later point elsewhere.
94
+ *
95
+ * Binding a missing source makes Docker auto-create a root-owned directory at the path —
96
+ * the same hazard `libStoreUsable` documents — which would also brick the embedder's
97
+ * later store install (its store tool cannot write into a root-owned dir).
98
+ */
99
+ function refStoreUsable(refStorePath) {
100
+ try {
101
+ return lstatSync(refStorePath).isDirectory();
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ }
83
107
  async function pollHealth(fetchImpl, url, timeoutMs) {
84
108
  const deadline = Date.now() + timeoutMs;
85
109
  let lastErr;
@@ -187,9 +211,16 @@ export function createDockerSandboxOps(config) {
187
211
  if (config.libStorePath && !libsMounted) {
188
212
  logger.warn("lib store configured but `current` is missing or incomplete at sandbox creation — mounting no library store (sandbox degrades to available:false)", { libStorePath: config.libStorePath, sandboxId });
189
213
  }
214
+ // Re-checked here too, so a ref store installed after boot is mounted into
215
+ // the next sandbox. Unlike libs, a missing ref store is NOT warned: a missing
216
+ // lib store is a degradation (it was configured and working, then broke),
217
+ // whereas a missing ref store is the normal cold state — the user simply has
218
+ // not installed reference data yet — and warning on every create would be
219
+ // noise. A store that appears mid-session is picked up by the next create.
220
+ const refsMounted = !!config.refStorePath && refStoreUsable(config.refStorePath);
190
221
  const plan = buildMountPlan(meta, {
191
222
  libs: libsMounted,
192
- refs: !!config.refStorePath,
223
+ refs: refsMounted,
193
224
  });
194
225
  const hostTreePath = config.resolveWorkspaceRoot(meta.analysisId);
195
226
  // `stepWritePrefix` (not a raw `join`) so the RW bind source runs through
@@ -200,7 +231,7 @@ export function createDockerSandboxOps(config) {
200
231
  `${hostTreePath}:${plan.readonlyTreePath}:ro`,
201
232
  ...(plan.writableStepPath ? [`${hostStepPath}:${plan.writableStepPath}:rw`] : []),
202
233
  ...(libsMounted && config.libStorePath ? [`${config.libStorePath}:${plan.libsPath}:ro`] : []),
203
- ...(config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
234
+ ...(refsMounted && config.refStorePath ? [`${config.refStorePath}:${plan.refsPath}:ro`] : []),
204
235
  ];
205
236
  const createFailed = (status, cause) => ({
206
237
  type: "container_create_failed",
@@ -245,6 +245,45 @@ CREATE TABLE IF NOT EXISTS cortex_working_memory (
245
245
  data JSONB NOT NULL,
246
246
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
247
247
  );
248
+
249
+ -- Tool-approval ask ledger — the single source of truth for approval state, one
250
+ -- row per ctx.ask invocation. ctx.ask inserts a 'pending' row and polls it to a
251
+ -- terminal status. The outward answer() writes the decision. reply holds the
252
+ -- recorded AskReply (NULL while pending). Analysis-scoped audit of every
253
+ -- approval-gated action, including grant auto-approvals (recorded 'resolved'
254
+ -- with no prompt shown). A pending row a dead process orphans is swept to
255
+ -- 'expired' at boot.
256
+ CREATE TABLE IF NOT EXISTS cortex_asks (
257
+ id TEXT PRIMARY KEY,
258
+ analysis_id TEXT NOT NULL,
259
+ thread_id TEXT,
260
+ title TEXT NOT NULL,
261
+ command TEXT NOT NULL,
262
+ detail TEXT,
263
+ status TEXT NOT NULL,
264
+ reply JSONB,
265
+ created_at TEXT NOT NULL,
266
+ resolved_at TEXT
267
+ );
268
+
269
+ CREATE INDEX IF NOT EXISTS idx_cortex_asks_analysis
270
+ ON cortex_asks(analysis_id);
271
+
272
+ -- Backs the pending() enumeration and the boot sweep. Partial so it stays
273
+ -- compact against the far larger terminal-row mass.
274
+ CREATE INDEX IF NOT EXISTS idx_cortex_asks_pending
275
+ ON cortex_asks(status) WHERE status = 'pending';
276
+
277
+ -- Standing approval grants behind an 'always' reply — keyed by the analysis and
278
+ -- the exact command string the user approved, so a later matching ask
279
+ -- auto-approves without pausing. Lives for the analysis lifecycle, surviving
280
+ -- process restarts, and never applies to another analysis.
281
+ CREATE TABLE IF NOT EXISTS cortex_ask_grants (
282
+ analysis_id TEXT NOT NULL,
283
+ command TEXT NOT NULL,
284
+ created_at TEXT NOT NULL,
285
+ PRIMARY KEY (analysis_id, command)
286
+ );
248
287
  `;
249
288
  /**
250
289
  * Initialize Cortex state tables. Safe to call on every startup and across
@@ -3,9 +3,11 @@
3
3
  * runner ignores it; imported by the tool unit tests.
4
4
  */
5
5
  import { makeSession } from "../../providers/__fixtures__/session.js";
6
+ import { UnavailableAsk } from "../approval/contract.js";
6
7
  /** Build a `ToolContext` for tests; `emit` records into `emitted`. */
7
8
  export function makeToolContext(signal) {
8
9
  const emitted = [];
10
+ const deny = new UnavailableAsk();
9
11
  const ctx = {
10
12
  session: makeSession(),
11
13
  signal: signal ?? new AbortController().signal,
@@ -13,6 +15,7 @@ export function makeToolContext(signal) {
13
15
  emitted.push(event);
14
16
  },
15
17
  runStep: (_name, fn) => fn(),
18
+ ask: (request) => deny.ask(request),
16
19
  };
17
20
  return { ctx, emitted };
18
21
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The tool-approval seam contract: the request a conversation tool pauses on,
3
+ * the decision the user returns, and the deny-by-default realization shipped for
4
+ * hosts that wire no interactive surface.
5
+ *
6
+ * The seam is generic — nothing here names a tool or a domain. A tool describes
7
+ * the concrete action it needs approved (`AskRequest`); a surface renders it, the
8
+ * user decides, and the decision comes back as an `AskApproval` on approval or an
9
+ * `AskRejectedError` throw on denial.
10
+ *
11
+ * These are standalone types so `define-tool.ts` can type-import them into
12
+ * `ToolContext` without an import cycle back through the tools layer.
13
+ */
14
+ /**
15
+ * The human-facing content a surface renders to describe the exact action being
16
+ * approved. `command` is the concrete operation the user sees and approves — what
17
+ * they saw is precisely what is granted, nothing broader. No tool- or
18
+ * domain-specific fields: the harness stays agnostic to what is being approved.
19
+ */
20
+ export interface AskRequest {
21
+ readonly title: string;
22
+ readonly command: string;
23
+ readonly detail?: string;
24
+ }
25
+ /**
26
+ * A user's decision on an approval request — three distinct outcomes, never a
27
+ * boolean. `once` approves the single pending invocation; `always` approves it
28
+ * AND records a standing grant for the matched action; `reject` denies and MAY
29
+ * carry model-facing feedback.
30
+ *
31
+ * This full three-variant union is the wire type the outward `answer` API takes;
32
+ * `ctx.ask` itself resolves only with the `AskApproval` subset (denial throws).
33
+ */
34
+ export type AskReply = {
35
+ readonly kind: "once";
36
+ } | {
37
+ readonly kind: "always";
38
+ } | {
39
+ readonly kind: "reject";
40
+ readonly feedback?: string;
41
+ };
42
+ /**
43
+ * The subset of `AskReply` `ctx.ask` may resolve with. Denial never returns — it
44
+ * throws `AskRejectedError` — so a resolved approval is only ever `once` or
45
+ * `always`, and a caller never has to inspect for a reject variant.
46
+ */
47
+ export type AskApproval = Exclude<AskReply, {
48
+ kind: "reject";
49
+ }>;
50
+ /**
51
+ * The throw a `reject` reply raises out of `ctx.ask`, carrying the optional
52
+ * model-facing feedback.
53
+ *
54
+ * Denial is a throw rather than a returned reject variant so a tool never has to
55
+ * inspect the reply to learn it was denied: only asks throw, so the throw itself
56
+ * is the denial signal, and the loop needs no per-tool "was denied" flag — it maps
57
+ * the caught error to an execution-denied tool result and stops the turn.
58
+ */
59
+ export declare class AskRejectedError extends Error {
60
+ readonly feedback?: string;
61
+ constructor(feedback?: string);
62
+ }
63
+ /**
64
+ * The user-approval seam. `ask` pauses on an approval request and resolves with
65
+ * the approval decision, or throws `AskRejectedError` when the user denies.
66
+ * Realizations are wired by the embedder; an unwired host resolves to
67
+ * `UnavailableAsk`.
68
+ */
69
+ export interface Ask {
70
+ ask(request: AskRequest): Promise<AskApproval>;
71
+ }
72
+ /**
73
+ * Deny-by-default realization — the shipped `Ask` for hosts that wire no
74
+ * interactive surface (workflow contexts, headless embedders). Every request is
75
+ * denied, so a tool that calls `ctx.ask` where nothing can answer is rejected
76
+ * rather than left waiting on a surface that will never respond.
77
+ */
78
+ export declare class UnavailableAsk implements Ask {
79
+ ask(_request: AskRequest): Promise<AskApproval>;
80
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The tool-approval seam contract: the request a conversation tool pauses on,
3
+ * the decision the user returns, and the deny-by-default realization shipped for
4
+ * hosts that wire no interactive surface.
5
+ *
6
+ * The seam is generic — nothing here names a tool or a domain. A tool describes
7
+ * the concrete action it needs approved (`AskRequest`); a surface renders it, the
8
+ * user decides, and the decision comes back as an `AskApproval` on approval or an
9
+ * `AskRejectedError` throw on denial.
10
+ *
11
+ * These are standalone types so `define-tool.ts` can type-import them into
12
+ * `ToolContext` without an import cycle back through the tools layer.
13
+ */
14
+ /**
15
+ * The throw a `reject` reply raises out of `ctx.ask`, carrying the optional
16
+ * model-facing feedback.
17
+ *
18
+ * Denial is a throw rather than a returned reject variant so a tool never has to
19
+ * inspect the reply to learn it was denied: only asks throw, so the throw itself
20
+ * is the denial signal, and the loop needs no per-tool "was denied" flag — it maps
21
+ * the caught error to an execution-denied tool result and stops the turn.
22
+ */
23
+ export class AskRejectedError extends Error {
24
+ feedback;
25
+ constructor(feedback) {
26
+ super(feedback ? `approval rejected: ${feedback}` : "approval rejected");
27
+ this.name = "AskRejectedError";
28
+ this.feedback = feedback;
29
+ }
30
+ }
31
+ /**
32
+ * Deny-by-default realization — the shipped `Ask` for hosts that wire no
33
+ * interactive surface (workflow contexts, headless embedders). Every request is
34
+ * denied, so a tool that calls `ctx.ask` where nothing can answer is rejected
35
+ * rather than left waiting on a surface that will never respond.
36
+ */
37
+ export class UnavailableAsk {
38
+ async ask(_request) {
39
+ throw new AskRejectedError("approval is unavailable in this environment");
40
+ }
41
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The poll-based tool-approval gateway — the constructed `Ask` seam realization
3
+ * an embedder wires over its Postgres pool, plus the outward `answer` / `pending`
4
+ * API it drives and the boot-time `sweepExpired`.
5
+ *
6
+ * `ask` is the per-turn seam an embedder binds: a conversation tool's `ctx.ask`
7
+ * pauses on it. The database is the single source of truth for ask state — `ask`
8
+ * inserts a `pending` ledger row, emits the `data-ask` part, and polls that row to
9
+ * a terminal status; there is no in-memory resolver map. `answer` is one guarded
10
+ * write. See the poll site below for why.
11
+ */
12
+ import type { Pool } from "pg";
13
+ import type { EmitFn } from "../../loop/types.js";
14
+ import { type AskApproval, type AskReply, type AskRequest } from "./contract.js";
15
+ import { type AnswerOutcome, type PendingAsk } from "./queries.js";
16
+ export type { AnswerOutcome, PendingAsk } from "./queries.js";
17
+ /** Construction deps: the harness application pool the ledger lives in. */
18
+ export interface AskGatewayDeps {
19
+ readonly pool: Pool;
20
+ }
21
+ /**
22
+ * Per-turn context an embedder binds into the `Ask` seam. It scopes the ask to an
23
+ * analysis/thread, carries the turn's abort `signal`, and supplies the `emit` sink
24
+ * the `data-ask` part flows through.
25
+ */
26
+ export interface AskContext {
27
+ readonly analysisId: string;
28
+ readonly threadId?: string;
29
+ readonly signal: AbortSignal;
30
+ readonly emit: EmitFn;
31
+ }
32
+ /** The gateway surface: the per-turn seam plus the outward embedder-driven API. */
33
+ export interface AskGateway {
34
+ ask(request: AskRequest, ctx: AskContext): Promise<AskApproval>;
35
+ answer(id: string, reply: AskReply): Promise<AnswerOutcome>;
36
+ pending(): Promise<PendingAsk[]>;
37
+ sweepExpired(): Promise<number>;
38
+ }
39
+ export declare function createAskGateway(deps: AskGatewayDeps): AskGateway;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The poll-based tool-approval gateway — the constructed `Ask` seam realization
3
+ * an embedder wires over its Postgres pool, plus the outward `answer` / `pending`
4
+ * API it drives and the boot-time `sweepExpired`.
5
+ *
6
+ * `ask` is the per-turn seam an embedder binds: a conversation tool's `ctx.ask`
7
+ * pauses on it. The database is the single source of truth for ask state — `ask`
8
+ * inserts a `pending` ledger row, emits the `data-ask` part, and polls that row to
9
+ * a terminal status; there is no in-memory resolver map. `answer` is one guarded
10
+ * write. See the poll site below for why.
11
+ */
12
+ import { unwrapOrThrow } from "../../lib/result.js";
13
+ import { AskRejectedError } from "./contract.js";
14
+ import { answerAsk, insertGrantedAsk, insertPendingAsk, markAborted, selectAskResolution, selectGrant, selectPending, sweepExpired as sweepExpiredRows, } from "./queries.js";
15
+ import { uuidv7 } from "./uuidv7.js";
16
+ /** Fixed poll cadence — imperceptible against human decision speed, low chatter. */
17
+ const POLL_INTERVAL_MS = 200;
18
+ export function createAskGateway(deps) {
19
+ const { pool } = deps;
20
+ async function ask(request, ctx) {
21
+ const id = uuidv7();
22
+ const now = new Date().toISOString();
23
+ const row = {
24
+ id,
25
+ analysisId: ctx.analysisId,
26
+ threadId: ctx.threadId ?? null,
27
+ title: request.title,
28
+ command: request.command,
29
+ detail: request.detail ?? null,
30
+ createdAt: now,
31
+ };
32
+ // A standing grant for this exact command short-circuits the prompt: record
33
+ // the invocation as `resolved` for the audit trail and return without pausing.
34
+ if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, request.command))) {
35
+ unwrapOrThrow(await insertGrantedAsk(pool, row, now));
36
+ return { kind: "always" };
37
+ }
38
+ unwrapOrThrow(await insertPendingAsk(pool, row));
39
+ await emitAskPart(ctx.emit, id, request, "pending");
40
+ // DESIGN(WHY): we poll the ask ledger instead of holding an in-memory
41
+ // resolver map. The DB is the single source of truth, so answering is a pure
42
+ // UPDATE, a crash-orphaned pending row just ends the poll, and an answer that
43
+ // arrives in a different process than the loop (a hosted deployment) still
44
+ // lands. A resolver map would be a second state to reconcile and cannot be
45
+ // resolved once its process dies. Latency is invisible at human decision speed
46
+ // and can be erased later with LISTEN/NOTIFY.
47
+ // TODO(refine): these ask records might move into provenance later.
48
+ for (;;) {
49
+ if (ctx.signal.aborted) {
50
+ unwrapOrThrow(await markAborted(pool, id, new Date().toISOString()));
51
+ await emitAskPart(ctx.emit, id, request, "aborted");
52
+ throwAbort(ctx.signal);
53
+ }
54
+ const resolution = unwrapOrThrow(await selectAskResolution(pool, id));
55
+ if (resolution === null) {
56
+ // The row vanished out from under the poll — nothing can answer it
57
+ // now, so deny rather than spin forever. Not reachable through the
58
+ // ordinary status machine (rows are only ever updated, never deleted).
59
+ await emitAskPart(ctx.emit, id, request, "expired");
60
+ throw new AskRejectedError("approval request is no longer available");
61
+ }
62
+ if (resolution.status !== "pending") {
63
+ await emitAskPart(ctx.emit, id, request, resolution.status);
64
+ return settle(resolution.status, resolution.reply, ctx.signal);
65
+ }
66
+ await sleepUntil(POLL_INTERVAL_MS, ctx.signal);
67
+ }
68
+ }
69
+ async function answer(id, reply) {
70
+ return unwrapOrThrow(await answerAsk(pool, id, reply, new Date().toISOString()));
71
+ }
72
+ async function pending() {
73
+ return unwrapOrThrow(await selectPending(pool));
74
+ }
75
+ async function sweepExpired() {
76
+ return unwrapOrThrow(await sweepExpiredRows(pool, new Date().toISOString()));
77
+ }
78
+ return { ask, answer, pending, sweepExpired };
79
+ }
80
+ /** Emit (or reconcile, under the same id) the ask's `data-ask` chat part. */
81
+ function emitAskPart(emit, id, request, status) {
82
+ return emit({
83
+ type: "data-ask",
84
+ data: {
85
+ id,
86
+ title: request.title,
87
+ command: request.command,
88
+ ...(request.detail !== undefined ? { detail: request.detail } : {}),
89
+ status,
90
+ },
91
+ });
92
+ }
93
+ /** Resolve an approval or throw the terminal denial the ledger recorded. */
94
+ function settle(status, reply, signal) {
95
+ switch (status) {
96
+ case "resolved":
97
+ // Only `once` / `always` reach `resolved`; guard the JSONB round-trip and
98
+ // default to the narrowest approval if a malformed reply ever slipped in.
99
+ return reply && (reply.kind === "once" || reply.kind === "always") ? reply : { kind: "once" };
100
+ case "rejected":
101
+ throw new AskRejectedError(reply && reply.kind === "reject" ? reply.feedback : undefined);
102
+ case "expired":
103
+ throw new AskRejectedError("approval request expired unanswered");
104
+ case "aborted":
105
+ throwAbort(signal);
106
+ }
107
+ }
108
+ /**
109
+ * Re-raise the turn's cancellation verbatim — `signal.reason` IS the loop's own
110
+ * cancellation, so its existing turn-abort path recognizes it unchanged. The
111
+ * fallback covers a signal aborted with no explicit reason.
112
+ */
113
+ function throwAbort(signal) {
114
+ throw signal.reason ?? new DOMException("The operation was aborted", "AbortError");
115
+ }
116
+ /** Delay `ms`, resolving early if `signal` aborts, leaving no dangling timer. */
117
+ function sleepUntil(ms, signal) {
118
+ return new Promise((resolve) => {
119
+ if (signal.aborted) {
120
+ resolve();
121
+ return;
122
+ }
123
+ const onAbort = () => {
124
+ clearTimeout(timer);
125
+ resolve();
126
+ };
127
+ const timer = setTimeout(() => {
128
+ signal.removeEventListener("abort", onAbort);
129
+ resolve();
130
+ }, ms);
131
+ signal.addEventListener("abort", onAbort, { once: true });
132
+ });
133
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Postgres data access for the tool-approval ledger (`cortex_asks`) and its
3
+ * standing grants (`cortex_ask_grants`).
4
+ *
5
+ * Every operation returns `ResultAsync<T, DbError>` in the `state/` house style —
6
+ * the `pg` throws are caught only inside `tryQuery`/`tryMutation`/`withTransaction`
7
+ * (`lib/db-result.ts`); everything above flows `Result`. Absence rides the ok
8
+ * channel (`ok(null)` / `ok(0)`), never an `err`. The gateway unwraps these at its
9
+ * seam boundary.
10
+ */
11
+ import { type ResultAsync } from "neverthrow";
12
+ import type { Pool, PoolClient } from "pg";
13
+ import { type DbError } from "../../lib/db-result.js";
14
+ import type { AskReply } from "./contract.js";
15
+ type Querier = Pool | PoolClient;
16
+ /** The ask ledger's status machine — one `pending` state, four terminal ones. */
17
+ export type AskStatus = "pending" | "resolved" | "rejected" | "aborted" | "expired";
18
+ /** The discriminated outcome of an out-of-band `answer` against a ledger row. */
19
+ export type AnswerOutcome = "applied" | "not_found" | "already_terminal";
20
+ /** An unresolved ask, as enumerated by `pending()`. */
21
+ export interface PendingAsk {
22
+ readonly id: string;
23
+ readonly analysisId: string;
24
+ readonly threadId: string | null;
25
+ readonly title: string;
26
+ readonly command: string;
27
+ readonly detail: string | null;
28
+ readonly createdAt: string;
29
+ }
30
+ /** The status + recorded reply a poll reads to observe a decision. */
31
+ export interface AskResolution {
32
+ readonly status: AskStatus;
33
+ readonly reply: AskReply | null;
34
+ }
35
+ /** Column payload shared by the pending insert and the grant short-circuit insert. */
36
+ export interface AskRow {
37
+ readonly id: string;
38
+ readonly analysisId: string;
39
+ readonly threadId: string | null;
40
+ readonly title: string;
41
+ readonly command: string;
42
+ readonly detail: string | null;
43
+ readonly createdAt: string;
44
+ }
45
+ /** Insert a fresh `pending` ask before its decision is awaited. */
46
+ export declare function insertPendingAsk(querier: Querier, row: AskRow): ResultAsync<void, DbError>;
47
+ /**
48
+ * Insert an already-`resolved` ask recording an auto-approval a standing grant
49
+ * short-circuited: no prompt was shown, but the ledger stays a complete audit of
50
+ * every approval-gated action. The recorded reply is `{ kind: "always" }`.
51
+ */
52
+ export declare function insertGrantedAsk(querier: Querier, row: AskRow, resolvedAt: string): ResultAsync<void, DbError>;
53
+ /** Does a standing grant cover `(analysisId, command)` in this analysis? */
54
+ export declare function selectGrant(querier: Querier, analysisId: string, command: string): ResultAsync<boolean, DbError>;
55
+ /** Read a row's current status + recorded reply. `null` when no such row exists. */
56
+ export declare function selectAskResolution(querier: Querier, id: string): ResultAsync<AskResolution | null, DbError>;
57
+ /**
58
+ * Mark a still-`pending` row `aborted`. Guarded by `WHERE status = 'pending'`, so
59
+ * a row already answered out of band is left untouched.
60
+ */
61
+ export declare function markAborted(querier: Querier, id: string, resolvedAt: string): ResultAsync<void, DbError>;
62
+ /** Enumerate every unresolved ask, oldest first. */
63
+ export declare function selectPending(querier: Querier): ResultAsync<PendingAsk[], DbError>;
64
+ /** Sweep every `pending` row to `expired`, returning the count swept. */
65
+ export declare function sweepExpired(querier: Querier, expiredAt: string): ResultAsync<number, DbError>;
66
+ /**
67
+ * Record a decision against a still-`pending` row and, for an `always` reply,
68
+ * write the standing grant — both in one transaction, so a crash between them can
69
+ * never record an approval without the grant it promised.
70
+ *
71
+ * The guarded `UPDATE ... WHERE status = 'pending'` makes a duplicate/stale answer
72
+ * a reported no-op: one row updated → `applied`; zero rows → a follow-up read
73
+ * discriminates `already_terminal` (the row exists but left `pending`) from
74
+ * `not_found` (no such id).
75
+ */
76
+ export declare function answerAsk(pool: Pool, id: string, reply: AskReply, now: string): ResultAsync<AnswerOutcome, DbError>;
77
+ export {};
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Postgres data access for the tool-approval ledger (`cortex_asks`) and its
3
+ * standing grants (`cortex_ask_grants`).
4
+ *
5
+ * Every operation returns `ResultAsync<T, DbError>` in the `state/` house style —
6
+ * the `pg` throws are caught only inside `tryQuery`/`tryMutation`/`withTransaction`
7
+ * (`lib/db-result.ts`); everything above flows `Result`. Absence rides the ok
8
+ * channel (`ok(null)` / `ok(0)`), never an `err`. The gateway unwraps these at its
9
+ * seam boundary.
10
+ */
11
+ import { okAsync } from "neverthrow";
12
+ import { tryMutation, tryQuery, withTransaction } from "../../lib/db-result.js";
13
+ /** Insert a fresh `pending` ask before its decision is awaited. */
14
+ export function insertPendingAsk(querier, row) {
15
+ return tryMutation("asks.insertPending", async () => {
16
+ await querier.query({
17
+ text: `INSERT INTO cortex_asks
18
+ (id, analysis_id, thread_id, title, command, detail, status, created_at)
19
+ VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)`,
20
+ values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, row.createdAt],
21
+ });
22
+ });
23
+ }
24
+ /**
25
+ * Insert an already-`resolved` ask recording an auto-approval a standing grant
26
+ * short-circuited: no prompt was shown, but the ledger stays a complete audit of
27
+ * every approval-gated action. The recorded reply is `{ kind: "always" }`.
28
+ */
29
+ export function insertGrantedAsk(querier, row, resolvedAt) {
30
+ return tryMutation("asks.insertGranted", async () => {
31
+ await querier.query({
32
+ text: `INSERT INTO cortex_asks
33
+ (id, analysis_id, thread_id, title, command, detail, status, reply, created_at, resolved_at)
34
+ VALUES ($1, $2, $3, $4, $5, $6, 'resolved', $7::jsonb, $8, $9)`,
35
+ values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, JSON.stringify({ kind: "always" }), row.createdAt, resolvedAt],
36
+ });
37
+ });
38
+ }
39
+ /** Does a standing grant cover `(analysisId, command)` in this analysis? */
40
+ export function selectGrant(querier, analysisId, command) {
41
+ return tryQuery("asks.selectGrant", async () => {
42
+ const result = await querier.query({
43
+ text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND command = $2`,
44
+ values: [analysisId, command],
45
+ });
46
+ return (result.rowCount ?? 0) > 0;
47
+ });
48
+ }
49
+ /** Read a row's current status + recorded reply. `null` when no such row exists. */
50
+ export function selectAskResolution(querier, id) {
51
+ return tryQuery("asks.selectResolution", async () => {
52
+ const result = await querier.query({
53
+ text: `SELECT status, reply FROM cortex_asks WHERE id = $1`,
54
+ values: [id],
55
+ });
56
+ const row = result.rows[0];
57
+ if (!row)
58
+ return null;
59
+ return { status: row.status, reply: row.reply ?? null };
60
+ });
61
+ }
62
+ /**
63
+ * Mark a still-`pending` row `aborted`. Guarded by `WHERE status = 'pending'`, so
64
+ * a row already answered out of band is left untouched.
65
+ */
66
+ export function markAborted(querier, id, resolvedAt) {
67
+ return tryMutation("asks.markAborted", async () => {
68
+ await querier.query({
69
+ text: `UPDATE cortex_asks SET status = 'aborted', resolved_at = $2 WHERE id = $1 AND status = 'pending'`,
70
+ values: [id, resolvedAt],
71
+ });
72
+ });
73
+ }
74
+ /** Enumerate every unresolved ask, oldest first. */
75
+ export function selectPending(querier) {
76
+ return tryQuery("asks.selectPending", async () => {
77
+ const result = await querier.query({
78
+ text: `SELECT id, analysis_id, thread_id, title, command, detail, created_at
79
+ FROM cortex_asks WHERE status = 'pending' ORDER BY created_at ASC`,
80
+ });
81
+ return result.rows.map((row) => ({
82
+ id: row.id,
83
+ analysisId: row.analysis_id,
84
+ threadId: row.thread_id ?? null,
85
+ title: row.title,
86
+ command: row.command,
87
+ detail: row.detail ?? null,
88
+ createdAt: row.created_at,
89
+ }));
90
+ });
91
+ }
92
+ /** Sweep every `pending` row to `expired`, returning the count swept. */
93
+ export function sweepExpired(querier, expiredAt) {
94
+ return tryMutation("asks.sweepExpired", async () => {
95
+ const result = await querier.query({
96
+ text: `UPDATE cortex_asks SET status = 'expired', resolved_at = $1 WHERE status = 'pending'`,
97
+ values: [expiredAt],
98
+ });
99
+ return result.rowCount ?? 0;
100
+ });
101
+ }
102
+ /**
103
+ * Record a decision against a still-`pending` row and, for an `always` reply,
104
+ * write the standing grant — both in one transaction, so a crash between them can
105
+ * never record an approval without the grant it promised.
106
+ *
107
+ * The guarded `UPDATE ... WHERE status = 'pending'` makes a duplicate/stale answer
108
+ * a reported no-op: one row updated → `applied`; zero rows → a follow-up read
109
+ * discriminates `already_terminal` (the row exists but left `pending`) from
110
+ * `not_found` (no such id).
111
+ */
112
+ export function answerAsk(pool, id, reply, now) {
113
+ const status = reply.kind === "reject" ? "rejected" : "resolved";
114
+ return withTransaction(pool, "asks.answer", (client) => tryMutation("asks.answer:update", () => client.query({
115
+ text: `UPDATE cortex_asks SET status = $1, reply = $2::jsonb, resolved_at = $3
116
+ WHERE id = $4 AND status = 'pending'
117
+ RETURNING analysis_id, command`,
118
+ values: [status, JSON.stringify(reply), now, id],
119
+ })).andThen((updated) => {
120
+ if ((updated.rowCount ?? 0) === 0)
121
+ return discriminateMiss(client, id);
122
+ if (reply.kind !== "always")
123
+ return okAsync("applied");
124
+ const granted = updated.rows[0];
125
+ return tryMutation("asks.answer:grant", async () => {
126
+ await client.query({
127
+ text: `INSERT INTO cortex_ask_grants (analysis_id, command, created_at)
128
+ VALUES ($1, $2, $3) ON CONFLICT (analysis_id, command) DO NOTHING`,
129
+ values: [granted.analysis_id, granted.command, now],
130
+ });
131
+ }).map(() => "applied");
132
+ }));
133
+ }
134
+ /** A zero-row `UPDATE` means the id is absent or already terminal — tell them apart. */
135
+ function discriminateMiss(client, id) {
136
+ return tryQuery("asks.answer:discriminate", async () => {
137
+ const result = await client.query({ text: `SELECT 1 FROM cortex_asks WHERE id = $1`, values: [id] });
138
+ return (result.rowCount ?? 0) > 0 ? "already_terminal" : "not_found";
139
+ });
140
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * UUIDv7 generator for the ask ledger id.
3
+ *
4
+ * The package pins no uuidv7 dependency and Node's `crypto.randomUUID` mints
5
+ * only v4, so a small self-contained helper covers the one call site that needs
6
+ * a time-ordered id: `cortex_asks.id`. v7's leading 48-bit millisecond timestamp
7
+ * makes ledger ids monotonically sortable, so a `created_at`-ordered scan and the
8
+ * id order agree without a second sort key.
9
+ */
10
+ /**
11
+ * Mint a RFC 9562 UUIDv7 — 48-bit big-endian unix-millis prefix, version/variant
12
+ * nibbles patched over otherwise-random bytes.
13
+ */
14
+ export declare function uuidv7(): string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * UUIDv7 generator for the ask ledger id.
3
+ *
4
+ * The package pins no uuidv7 dependency and Node's `crypto.randomUUID` mints
5
+ * only v4, so a small self-contained helper covers the one call site that needs
6
+ * a time-ordered id: `cortex_asks.id`. v7's leading 48-bit millisecond timestamp
7
+ * makes ledger ids monotonically sortable, so a `created_at`-ordered scan and the
8
+ * id order agree without a second sort key.
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+ /**
12
+ * Mint a RFC 9562 UUIDv7 — 48-bit big-endian unix-millis prefix, version/variant
13
+ * nibbles patched over otherwise-random bytes.
14
+ */
15
+ export function uuidv7() {
16
+ const bytes = randomBytes(16);
17
+ // 48-bit timestamp occupies the first six bytes, big-endian. Date.now() fits
18
+ // in 48 bits well past the year 10000, so writeUIntBE never overflows.
19
+ bytes.writeUIntBE(Date.now(), 0, 6);
20
+ // Version 7 in the high nibble of byte 6; variant 0b10 in the top bits of byte 8.
21
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
22
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
23
+ const hex = bytes.toString("hex");
24
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
25
+ }
@@ -7,8 +7,9 @@
7
7
  * `defineTool(...)`; a dependency-bearing tool is a factory closure that
8
8
  * captures its deps and calls `defineTool`.
9
9
  *
10
- * `ToolContext` carries exactly the four request-scoped values every tool
11
- * may need — no pool, no sandbox, no logger. The error contract: an expected
10
+ * `ToolContext` carries only request-scoped values every tool may need
11
+ * (`session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
12
+ * logger. The error contract: an expected
12
13
  * outcome ("not found", "no results") stays in the ok channel as a data
13
14
  * variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
14
15
  * or a throw. The loop (`dispatchTool`) owns the `is_error` envelope — it maps
@@ -18,6 +19,7 @@ import type { Result } from "neverthrow";
18
19
  import { z } from "zod";
19
20
  import type { AgentSession } from "../auth/types.js";
20
21
  import type { EmitFn, RunStep } from "../loop/types.js";
22
+ import type { AskApproval, AskRequest } from "./approval/contract.js";
21
23
  export type { EmitFn };
22
24
  /**
23
25
  * The error channel of a tool's `Result`. `error` is the message surfaced to
@@ -37,9 +39,9 @@ export type ToolExecutionMode = "step" | "workflow" | "inline";
37
39
  export declare function isToolError(value: unknown): value is ToolError;
38
40
  /**
39
41
  * The request-scoped values passed to every tool's `execute`. No injected
40
- * dependencies (see the harness-durable-runtime spec) — `session`, `signal`, `emit`, plus the `runStep`
41
- * durability seam (`passthroughStep` in chat, `DBOS.runStep` in workflows)
42
- * a tool uses to wrap its own durable work.
42
+ * dependencies (see the harness-durable-runtime spec) — `session`, `signal`, `emit`, the `runStep`
43
+ * durability seam (`passthroughStep` in chat, `DBOS.runStep` in workflows) a
44
+ * tool uses to wrap its own durable work, and the `ask` user-approval seam.
43
45
  */
44
46
  export interface ToolContext {
45
47
  readonly session: AgentSession;
@@ -50,6 +52,14 @@ export interface ToolContext {
50
52
  * under the tool's own step name, so a tool just passes a short local label.
51
53
  */
52
54
  readonly runStep: RunStep;
55
+ /**
56
+ * Pause for an explicit user decision on a concrete action. Resolves with the
57
+ * approval (`once`/`always`) or throws `AskRejectedError` on denial. Resolves
58
+ * to a deny-by-default realization when the embedder wires none, so a tool
59
+ * that calls it in a non-interactive context is denied rather than left
60
+ * waiting on a surface that cannot answer.
61
+ */
62
+ readonly ask: (request: AskRequest) => Promise<AskApproval>;
53
63
  }
54
64
  /**
55
65
  * A packaged tool: identity, the Zod input contract, the emitted AI SDK input
@@ -7,8 +7,9 @@
7
7
  * `defineTool(...)`; a dependency-bearing tool is a factory closure that
8
8
  * captures its deps and calls `defineTool`.
9
9
  *
10
- * `ToolContext` carries exactly the four request-scoped values every tool
11
- * may need — no pool, no sandbox, no logger. The error contract: an expected
10
+ * `ToolContext` carries only request-scoped values every tool may need
11
+ * (`session`, `signal`, `emit`, `runStep`, `ask`) — no pool, no sandbox, no
12
+ * logger. The error contract: an expected
12
13
  * outcome ("not found", "no results") stays in the ok channel as a data
13
14
  * variant (`ok({ found: false })`); an unexpected failure is an `err(ToolError)`
14
15
  * or a throw. The loop (`dispatchTool`) owns the `is_error` envelope — it maps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",