@stigmer/runner 3.2.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
- package/dist/activities/execute-cursor/approval-state.js +13 -1
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/blueprint-resolver.d.ts +9 -1
- package/dist/activities/execute-cursor/blueprint-resolver.js +1 -0
- package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +38 -0
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +2 -0
- package/dist/activities/execute-cursor/index.js +56 -13
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
- package/dist/activities/execute-cursor/message-translator.js +78 -2
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +7 -1
- package/dist/activities/execute-cursor/prompt-builder.js +4 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
- package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.js +15 -2
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
- package/dist/activities/execute-deep-agent/hitl.js +48 -1
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +10 -1
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +5 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +3 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
- package/dist/activities/execute-deep-agent/setup.js +36 -3
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +9 -0
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +20 -0
- package/dist/middleware/approval-gate.js +16 -1
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.js +3 -0
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +24 -1
- package/dist/shared/approval-policy.js +35 -1
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/datastore-attachment.d.ts +79 -0
- package/dist/shared/datastore-attachment.js +129 -0
- package/dist/shared/datastore-attachment.js.map +1 -0
- package/dist/shared/with-timeout.d.ts +10 -0
- package/dist/shared/with-timeout.js +14 -1
- package/dist/shared/with-timeout.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.js +1 -0
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +22 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/human-input-orchestrator.d.ts +2 -1
- package/dist/workflows/human-input-orchestrator.js +2 -1
- package/dist/workflows/human-input-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +29 -0
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +25 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
- package/src/activities/execute-cursor/approval-state.ts +32 -1
- package/src/activities/execute-cursor/blueprint-resolver.ts +10 -1
- package/src/activities/execute-cursor/hook-script.ts +40 -0
- package/src/activities/execute-cursor/index.ts +64 -15
- package/src/activities/execute-cursor/message-translator.ts +82 -1
- package/src/activities/execute-cursor/prompt-builder.ts +12 -1
- package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
- package/src/activities/execute-cursor/turn-boundary.ts +23 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
- package/src/activities/execute-deep-agent/hitl.ts +51 -0
- package/src/activities/execute-deep-agent/index.ts +11 -1
- package/src/activities/execute-deep-agent/prompt-builder.ts +9 -0
- package/src/activities/execute-deep-agent/setup.ts +57 -2
- package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
- package/src/activities/execute-deep-agent/status-builder.ts +7 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
- package/src/activities/workflow-event-activities.ts +9 -0
- package/src/config.ts +11 -0
- package/src/middleware/__tests__/approval-gate.test.ts +95 -0
- package/src/middleware/approval-gate.ts +37 -0
- package/src/runner-manager.ts +3 -0
- package/src/runner.ts +3 -0
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/datastore-attachment.test.ts +165 -0
- package/src/shared/__tests__/with-timeout.test.ts +16 -1
- package/src/shared/approval-policy.ts +41 -2
- package/src/shared/datastore-attachment.ts +166 -0
- package/src/shared/with-timeout.ts +15 -1
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
- package/src/workflow-engine/tasks/human-input.ts +1 -0
- package/src/workflow-engine/types.ts +23 -0
- package/src/workflows/human-input-orchestrator.ts +2 -1
|
@@ -47,7 +47,7 @@ import { StatusBuilder } from "./status-builder.js";
|
|
|
47
47
|
import { InlinePublisher } from "./inline-publisher.js";
|
|
48
48
|
import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
|
|
49
49
|
import { processPostStream } from "./post-stream.js";
|
|
50
|
-
import { resolveResumeInput, reconcileNonExecutingDecisions, type GraphStateSnapshot } from "./hitl.js";
|
|
50
|
+
import { resolveResumeInput, reconcileNonExecutingDecisions, reconcileUnattendedSkips, type GraphStateSnapshot } from "./hitl.js";
|
|
51
51
|
import { captureApprovalArtifacts } from "./approval-file-change.js";
|
|
52
52
|
import {
|
|
53
53
|
applyCaptureDecisions,
|
|
@@ -144,6 +144,7 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
144
144
|
toolServerMap: setup.toolServerMap,
|
|
145
145
|
leasedCategories: setup.leasedCategories,
|
|
146
146
|
globalBypass: setup.globalBypass,
|
|
147
|
+
unattended: setup.unattended,
|
|
147
148
|
});
|
|
148
149
|
|
|
149
150
|
const resume = resolveResumeInput(
|
|
@@ -408,6 +409,7 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
408
409
|
toolServerMap: setup.toolServerMap,
|
|
409
410
|
leasedCategories: setup.leasedCategories,
|
|
410
411
|
globalBypass: setup.globalBypass,
|
|
412
|
+
unattended: setup.unattended,
|
|
411
413
|
},
|
|
412
414
|
streamVersion: setup.streamVersion,
|
|
413
415
|
// Mid-run live capture (DD-32 / DD-33): attach file_change_progress
|
|
@@ -465,6 +467,14 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
465
467
|
// completion persists below so the terminal status is what is persisted.
|
|
466
468
|
reconcileNonExecutingDecisions(initialStatus);
|
|
467
469
|
|
|
470
|
+
// Terminalize every tool call the gate auto-skipped under UNATTENDED
|
|
471
|
+
// approval mode (DD-014): the skip has no human decision behind it, so
|
|
472
|
+
// reconcileNonExecutingDecisions cannot see it — this sibling folds the
|
|
473
|
+
// gate's registry into terminal SKIPPED rows with UNATTENDED_SKIP
|
|
474
|
+
// provenance, whatever transient status the stream left behind. No-op
|
|
475
|
+
// for interactive executions (empty registry).
|
|
476
|
+
reconcileUnattendedSkips(initialStatus, setup.unattendedSkips);
|
|
477
|
+
|
|
468
478
|
// Turn boundary (capture mode): capture the candidate change set from the
|
|
469
479
|
// git diff and author CANDIDATE_CAPTURED, then stamp the flowed file-edit
|
|
470
480
|
// rows with the change set id — they stay visible in place as
|
|
@@ -92,6 +92,11 @@ export interface PromptBuilderInput {
|
|
|
92
92
|
provisionResults: ProvisionResult[];
|
|
93
93
|
containerRoot: string;
|
|
94
94
|
skillsPromptSection: string;
|
|
95
|
+
/**
|
|
96
|
+
* The `<available_datastores>` section (shared/datastore-attachment.ts
|
|
97
|
+
* formatDatastoresSection); empty when the agent uses no datastores.
|
|
98
|
+
*/
|
|
99
|
+
datastoresPromptSection?: string;
|
|
95
100
|
workspaceFileRefs: string[];
|
|
96
101
|
workspaceRoot: string;
|
|
97
102
|
injectedFiles: InjectedFile[];
|
|
@@ -156,6 +161,10 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
|
|
|
156
161
|
prompt += input.skillsPromptSection;
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
if (input.datastoresPromptSection) {
|
|
165
|
+
prompt += "\n\n" + input.datastoresPromptSection;
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
if (input.workspaceFileRefs.length > 0) {
|
|
160
169
|
const refSection = buildReferencedFilesSection(
|
|
161
170
|
input.workspaceFileRefs,
|
|
@@ -28,6 +28,11 @@ import { readSenderIdentity } from "../../shared/sender-identity.js";
|
|
|
28
28
|
import { connectMcpServers, type McpConnectionResult } from "../../shared/mcp-manager.js";
|
|
29
29
|
import { resolveMcpServers } from "../../shared/mcp-resolver.js";
|
|
30
30
|
import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
|
|
31
|
+
import {
|
|
32
|
+
formatDatastoresSection,
|
|
33
|
+
injectDatastoreAttachment,
|
|
34
|
+
synthesizeDatastoreAttachment,
|
|
35
|
+
} from "../../shared/datastore-attachment.js";
|
|
31
36
|
import { WorkspaceProvisioner } from "../../shared/workspace/provisioner.js";
|
|
32
37
|
import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
33
38
|
import type { WorkspaceBackend, ProvisionResult } from "../../shared/workspace/types.js";
|
|
@@ -59,6 +64,7 @@ import {
|
|
|
59
64
|
import {
|
|
60
65
|
mergeApprovalPolicies,
|
|
61
66
|
deriveActiveLeases,
|
|
67
|
+
isUnattendedApprovalMode,
|
|
62
68
|
type MergedToolPolicy,
|
|
63
69
|
} from "../../shared/approval-policy.js";
|
|
64
70
|
import type { ToolApprovalCategory } from "../../shared/tool-kind.js";
|
|
@@ -125,6 +131,20 @@ export interface SetupResult {
|
|
|
125
131
|
* the gate (see ActiveLeases / deriveActiveLeases).
|
|
126
132
|
*/
|
|
127
133
|
readonly globalBypass: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED,
|
|
136
|
+
* stamped by approver-less surfaces — channels, guest shares). The gate
|
|
137
|
+
* resolves gated tools as automatic skips instead of interrupting; the
|
|
138
|
+
* builders never seed WAITING_APPROVAL. See isUnattendedApprovalMode.
|
|
139
|
+
*/
|
|
140
|
+
readonly unattended: boolean;
|
|
141
|
+
/**
|
|
142
|
+
* Tool-call ids the gate auto-skipped under {@link unattended} this turn.
|
|
143
|
+
* Written ONLY by the approval gate (parent + inherited sub-agent gates
|
|
144
|
+
* share this instance); read by reconcileUnattendedSkips after the stream
|
|
145
|
+
* to stamp terminal SKIPPED rows. Empty set when not unattended.
|
|
146
|
+
*/
|
|
147
|
+
readonly unattendedSkips: Set<string>;
|
|
128
148
|
readonly hasStructuredOutput: boolean;
|
|
129
149
|
readonly streamVersion: "v2" | "v3";
|
|
130
150
|
/**
|
|
@@ -299,16 +319,17 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
299
319
|
...(agent.spec!.mcpServerUsages || []),
|
|
300
320
|
...(session.spec!.mcpServerUsages || []),
|
|
301
321
|
];
|
|
322
|
+
const datastoreUsages = agent.spec!.datastoreUsages || [];
|
|
302
323
|
|
|
303
324
|
let resolvedMcpServers: Awaited<ReturnType<typeof resolveMcpServers>> | null = null;
|
|
304
|
-
if (mcpServerUsages.length > 0) {
|
|
325
|
+
if (mcpServerUsages.length > 0 || datastoreUsages.length > 0) {
|
|
305
326
|
await reportSetupProgress(client, executionId, "Connecting tools…");
|
|
306
327
|
resolvedMcpServers = await resolveMcpServers(
|
|
307
328
|
client, mcpServerUsages, envResult.mergedEnvVars,
|
|
308
329
|
);
|
|
309
330
|
|
|
310
331
|
const sessionOrg = session.metadata?.org ?? "";
|
|
311
|
-
|
|
332
|
+
let backfilledServers = await backfillMcpServersIfNeeded(
|
|
312
333
|
client,
|
|
313
334
|
resolvedMcpServers.resolvedServers,
|
|
314
335
|
mcpServerUsages,
|
|
@@ -317,6 +338,25 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
317
338
|
undefined,
|
|
318
339
|
envResult.secretKeys,
|
|
319
340
|
);
|
|
341
|
+
|
|
342
|
+
// The datastore records attachment (T05) — injected AFTER resolve +
|
|
343
|
+
// backfill so the destructiveHint tightener can never gate it; empty
|
|
344
|
+
// approval maps keep it approval-free by construction (see
|
|
345
|
+
// shared/datastore-attachment.ts).
|
|
346
|
+
if (datastoreUsages.length > 0) {
|
|
347
|
+
const scopedCredential =
|
|
348
|
+
(await client.acquireScopedRunnerToken({ agentExecutionId: executionId }))
|
|
349
|
+
?? config.stigmerTokenRef?.current
|
|
350
|
+
?? config.stigmerToken;
|
|
351
|
+
const attachment = synthesizeDatastoreAttachment(datastoreUsages, {
|
|
352
|
+
bridgeEndpoint: config.mcpBridgeEndpoint,
|
|
353
|
+
credential: scopedCredential,
|
|
354
|
+
backendEndpoint: config.stigmerBackendEndpoint,
|
|
355
|
+
});
|
|
356
|
+
if (attachment) {
|
|
357
|
+
backfilledServers = injectDatastoreAttachment(backfilledServers, attachment);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
320
360
|
resolvedMcpServers = { resolvedServers: backfilledServers };
|
|
321
361
|
|
|
322
362
|
mcpConnection = await connectMcpServers(
|
|
@@ -383,6 +423,9 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
383
423
|
provisionResults,
|
|
384
424
|
containerRoot: workspaceBackend.rootDir,
|
|
385
425
|
skillsPromptSection,
|
|
426
|
+
datastoresPromptSection: datastoreUsages.length > 0
|
|
427
|
+
? formatDatastoresSection(datastoreUsages)
|
|
428
|
+
: undefined,
|
|
386
429
|
workspaceFileRefs: execution.spec!.workspaceFileRefs || [],
|
|
387
430
|
workspaceRoot: workspaceBackend.rootDir,
|
|
388
431
|
injectedFiles,
|
|
@@ -440,6 +483,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
440
483
|
leases,
|
|
441
484
|
);
|
|
442
485
|
|
|
486
|
+
// Unattended approval mode (DD-014): approver-less surfaces (channels,
|
|
487
|
+
// guest shares) stamp APPROVAL_MODE_UNATTENDED, and the gate resolves
|
|
488
|
+
// gated tools as automatic skips instead of interrupting. The registry is
|
|
489
|
+
// the gate→reconciler channel for the skipped tool-call ids; one instance
|
|
490
|
+
// is shared with sub-agent gates via the inherited config.
|
|
491
|
+
const unattended = isUnattendedApprovalMode(execution);
|
|
492
|
+
const unattendedSkips = new Set<string>();
|
|
493
|
+
|
|
443
494
|
// The approval gate config is the single source of truth for HITL gating,
|
|
444
495
|
// built once and inherited verbatim by sub-agents (so a mutating tool inside
|
|
445
496
|
// a sub-agent is gated identically to one in the parent). Null under the
|
|
@@ -476,6 +527,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
476
527
|
isCapturablePath,
|
|
477
528
|
captureIgnored: captureMode && !!artifactStorage,
|
|
478
529
|
recordBlockedSecret: (rawPath: string) => casObserver.recordBlockedSecret(rawPath),
|
|
530
|
+
unattended,
|
|
531
|
+
unattendedSkips,
|
|
479
532
|
}
|
|
480
533
|
: null;
|
|
481
534
|
|
|
@@ -648,6 +701,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
648
701
|
toolServerMap,
|
|
649
702
|
leasedCategories: leases.categories,
|
|
650
703
|
globalBypass,
|
|
704
|
+
unattended,
|
|
705
|
+
unattendedSkips,
|
|
651
706
|
hasStructuredOutput: !!outputSchema,
|
|
652
707
|
streamVersion,
|
|
653
708
|
casObserver,
|
|
@@ -157,6 +157,14 @@ export interface ApprovalProvenanceInputs {
|
|
|
157
157
|
readonly leasedCategories?: ReadonlySet<ToolApprovalCategory>;
|
|
158
158
|
/** Pre-armed spec.auto_approve_all — the one whole-run global bypass. */
|
|
159
159
|
readonly globalBypass: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
|
|
162
|
+
* the gate auto-skips gated calls instead of interrupting, so the builders
|
|
163
|
+
* must NOT seed WAITING_APPROVAL rows or flip the phase to
|
|
164
|
+
* WAITING_FOR_APPROVAL — the row streams as RUNNING and the post-stream
|
|
165
|
+
* `reconcileUnattendedSkips` stamps the terminal SKIPPED + provenance.
|
|
166
|
+
*/
|
|
167
|
+
readonly unattended?: boolean;
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
/** Shared empty set so a provider without leases allocates nothing per call. */
|
|
@@ -319,6 +319,13 @@ export class StatusBuilder {
|
|
|
319
319
|
return { requiresApproval: false, message: "", serverSlug };
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// Unattended mode: the gate auto-skips instead of interrupting, so no row
|
|
323
|
+
// is ever WAITING_APPROVAL and the phase never flips — the streamed row
|
|
324
|
+
// runs to its skip result and reconcileUnattendedSkips terminalizes it.
|
|
325
|
+
if (this.approvalProvider.unattended) {
|
|
326
|
+
return { requiresApproval: false, message: "", serverSlug };
|
|
327
|
+
}
|
|
328
|
+
|
|
322
329
|
if (serverSlug) {
|
|
323
330
|
const key = `${serverSlug}/${toolName}`;
|
|
324
331
|
const policy = this.approvalProvider.policies.get(key);
|
|
@@ -419,6 +419,13 @@ export class V3StatusBuilder implements ExecutionStatusWriter {
|
|
|
419
419
|
return { requiresApproval: false, message: "", serverSlug };
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
// Unattended mode: the gate auto-skips instead of interrupting, so no row
|
|
423
|
+
// is ever WAITING_APPROVAL and the phase never flips — the streamed row
|
|
424
|
+
// runs to its skip result and reconcileUnattendedSkips terminalizes it.
|
|
425
|
+
if (this.approvalProvider.unattended) {
|
|
426
|
+
return { requiresApproval: false, message: "", serverSlug };
|
|
427
|
+
}
|
|
428
|
+
|
|
422
429
|
if (serverSlug) {
|
|
423
430
|
const key = `${serverSlug}/${toolName}`;
|
|
424
431
|
const policy = this.approvalProvider.policies.get(key);
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
ArtifactCreatedPayloadSchema,
|
|
35
35
|
HumanInputOutcomeInfoSchema,
|
|
36
36
|
} from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
|
|
37
|
+
import { ApiResourceAuditActorSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/status_pb";
|
|
37
38
|
import { WorkflowExecutionStatusSchema, WorkflowTaskSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
|
|
38
39
|
import {
|
|
39
40
|
WorkflowExecutionUpdateStatusInputSchema,
|
|
@@ -309,6 +310,14 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
|
|
|
309
310
|
case: "approvalResolved",
|
|
310
311
|
value: create(ApprovalResolvedPayloadSchema, {
|
|
311
312
|
resolvedBy: desc.resolvedBy,
|
|
313
|
+
resolvedByActor: desc.resolvedByActor
|
|
314
|
+
? create(ApiResourceAuditActorSchema, {
|
|
315
|
+
id: desc.resolvedByActor.id,
|
|
316
|
+
displayName: desc.resolvedByActor.display_name ?? "",
|
|
317
|
+
email: desc.resolvedByActor.email ?? "",
|
|
318
|
+
avatar: desc.resolvedByActor.avatar ?? "",
|
|
319
|
+
})
|
|
320
|
+
: undefined,
|
|
312
321
|
comment: desc.comment,
|
|
313
322
|
waitDurationMs: BigInt(desc.waitDurationMs),
|
|
314
323
|
}),
|
package/src/config.ts
CHANGED
|
@@ -60,6 +60,14 @@ export interface Config {
|
|
|
60
60
|
readonly temporalNamespace: string;
|
|
61
61
|
readonly stigmerBackendEndpoint: string;
|
|
62
62
|
readonly stigmerToken: string | null;
|
|
63
|
+
/**
|
|
64
|
+
* The MCP bridge endpoint for the runner-synthesized datastore
|
|
65
|
+
* records attachment (STIGMER_MCP_BRIDGE_ENDPOINT, e.g.
|
|
66
|
+
* https://mcp.stigmer.ai). Null selects the OSS/local shape: a
|
|
67
|
+
* spawned `stigmer mcp-server` stdio child against the local backend.
|
|
68
|
+
* See shared/datastore-attachment.ts.
|
|
69
|
+
*/
|
|
70
|
+
readonly mcpBridgeEndpoint: string | null;
|
|
63
71
|
/**
|
|
64
72
|
* Bearer credential for the Cursor SDK's Connect RPC transport.
|
|
65
73
|
*
|
|
@@ -153,6 +161,8 @@ export function loadConfig(): Config {
|
|
|
153
161
|
? requireEnv("STIGMER_TOKEN")
|
|
154
162
|
: (process.env.STIGMER_TOKEN ?? null);
|
|
155
163
|
|
|
164
|
+
const mcpBridgeEndpoint = process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null;
|
|
165
|
+
|
|
156
166
|
// In proxy mode, pass STIGMER_TOKEN as the SDK's API key. The SDK exchanges
|
|
157
167
|
// it (via REST proxy → Tomcat → Cursor) for an access token. The HTTP/2
|
|
158
168
|
// interceptor injects x-stigmer-auth for BiDi proxy authentication while
|
|
@@ -202,6 +212,7 @@ export function loadConfig(): Config {
|
|
|
202
212
|
temporalNamespace,
|
|
203
213
|
stigmerBackendEndpoint,
|
|
204
214
|
stigmerToken,
|
|
215
|
+
mcpBridgeEndpoint,
|
|
205
216
|
cursorApiKey,
|
|
206
217
|
workspaceRootDir,
|
|
207
218
|
mode,
|
|
@@ -784,4 +784,99 @@ describe("ApprovalGateMiddleware", () => {
|
|
|
784
784
|
expect(result).toBeInstanceOf(ToolMessage);
|
|
785
785
|
expect((result as ToolMessage).content).toContain("unknown action");
|
|
786
786
|
});
|
|
787
|
+
|
|
788
|
+
describe("unattended approval mode (DD-014)", () => {
|
|
789
|
+
const gatedMcpPolicies = new Map<string, MergedToolPolicy>([
|
|
790
|
+
["srv/gated_tool", {
|
|
791
|
+
toolName: "gated_tool",
|
|
792
|
+
mcpServerSlug: "srv",
|
|
793
|
+
requiresApproval: true,
|
|
794
|
+
approvalMessage: "Run gated_tool",
|
|
795
|
+
source: "classifier_default",
|
|
796
|
+
}],
|
|
797
|
+
]);
|
|
798
|
+
|
|
799
|
+
it("skips a gated MCP tool without interrupting and records it in the registry", async () => {
|
|
800
|
+
const unattendedSkips = new Set<string>();
|
|
801
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
802
|
+
policies: gatedMcpPolicies,
|
|
803
|
+
toolServerMap: new Map([["gated_tool", "srv"]]),
|
|
804
|
+
unattended: true,
|
|
805
|
+
unattendedSkips,
|
|
806
|
+
}));
|
|
807
|
+
|
|
808
|
+
const handler = vi.fn(passthrough);
|
|
809
|
+
const result = await mw.wrapToolCall!(
|
|
810
|
+
makeRequest({ name: "gated_tool", args: { target: "prod" } }),
|
|
811
|
+
handler,
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// Gateway invariant: the side effect never ran, and no interrupt was
|
|
815
|
+
// raised — the turn continues instead of parking WAITING_FOR_APPROVAL.
|
|
816
|
+
expect(handler).not.toHaveBeenCalled();
|
|
817
|
+
expect(mockedInterrupt).not.toHaveBeenCalled();
|
|
818
|
+
expect((result as ToolMessage).content).toContain("skipped automatically");
|
|
819
|
+
expect(unattendedSkips.has("call_abc123")).toBe(true);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("skips a gated built-in (shell) the same way", async () => {
|
|
823
|
+
const unattendedSkips = new Set<string>();
|
|
824
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
825
|
+
unattended: true,
|
|
826
|
+
unattendedSkips,
|
|
827
|
+
}));
|
|
828
|
+
|
|
829
|
+
const handler = vi.fn(passthrough);
|
|
830
|
+
const result = await mw.wrapToolCall!(
|
|
831
|
+
makeRequest({ name: "shell", args: { command: "rm -rf /" } }),
|
|
832
|
+
handler,
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
expect(handler).not.toHaveBeenCalled();
|
|
836
|
+
expect(mockedInterrupt).not.toHaveBeenCalled();
|
|
837
|
+
expect((result as ToolMessage).content).toContain("skipped automatically");
|
|
838
|
+
expect(unattendedSkips.has("call_abc123")).toBe(true);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it("still runs tools the policy chain auto-approved (un-gating stays the operator lever)", async () => {
|
|
842
|
+
// The operator's tool_approval_overrides path: an un-gated tool is
|
|
843
|
+
// absent from the policy map, so unattended mode never touches it.
|
|
844
|
+
const unattendedSkips = new Set<string>();
|
|
845
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
846
|
+
toolServerMap: new Map([["book_appointment", "clinic"]]),
|
|
847
|
+
unattended: true,
|
|
848
|
+
unattendedSkips,
|
|
849
|
+
}));
|
|
850
|
+
|
|
851
|
+
const result = await mw.wrapToolCall!(
|
|
852
|
+
makeRequest({ name: "book_appointment", args: { slot: "mon-10" } }),
|
|
853
|
+
passthrough,
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
expect((result as ToolMessage).content).toBe("tool result");
|
|
857
|
+
expect(unattendedSkips.size).toBe(0);
|
|
858
|
+
expect(mockedInterrupt).not.toHaveBeenCalled();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("never emits an execution receipt for an unattended skip (no side effect)", async () => {
|
|
862
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
863
|
+
try {
|
|
864
|
+
const mw = createApprovalGateMiddleware(makeConfig({
|
|
865
|
+
policies: gatedMcpPolicies,
|
|
866
|
+
toolServerMap: new Map([["gated_tool", "srv"]]),
|
|
867
|
+
unattended: true,
|
|
868
|
+
unattendedSkips: new Set<string>(),
|
|
869
|
+
}));
|
|
870
|
+
|
|
871
|
+
await mw.wrapToolCall!(makeRequest({ name: "gated_tool" }), passthrough);
|
|
872
|
+
|
|
873
|
+
const receipts = logSpy.mock.calls
|
|
874
|
+
.map((c) => String(c[0]))
|
|
875
|
+
.filter((line) => line.includes("[hitl-gateway] receipt"));
|
|
876
|
+
expect(receipts).toHaveLength(0);
|
|
877
|
+
} finally {
|
|
878
|
+
logSpy.mockRestore();
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
});
|
|
787
882
|
});
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
type PolicySource,
|
|
52
52
|
POLICY_ENGINE_VERSION,
|
|
53
53
|
resolveApprovalMessage,
|
|
54
|
+
unattendedSkipMessage,
|
|
54
55
|
} from "../shared/approval-policy.js";
|
|
55
56
|
import { toolApprovalCategory, type ToolApprovalCategory } from "../shared/tool-kind.js";
|
|
56
57
|
import { extractFilePath } from "../shared/file-tools.js";
|
|
@@ -121,6 +122,26 @@ export interface ApprovalGateConfig {
|
|
|
121
122
|
* secret; the CONTENT never leaves the workspace). Absent ⇒ nothing recorded.
|
|
122
123
|
*/
|
|
123
124
|
readonly recordBlockedSecret?: (rawPath: string) => void;
|
|
125
|
+
/**
|
|
126
|
+
* Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
|
|
127
|
+
* the creating surface — a messaging channel, a guest share — has no
|
|
128
|
+
* approver, so a gated tool is resolved as an automatic SKIP (the model is
|
|
129
|
+
* told to adapt) instead of `interrupt()`. The execution never enters
|
|
130
|
+
* WAITING_FOR_APPROVAL. What is gated is unchanged — only the resolution
|
|
131
|
+
* differs; an operator un-gates a specific tool for the agent via
|
|
132
|
+
* tool_approval_overrides, not by weakening this mode.
|
|
133
|
+
*/
|
|
134
|
+
readonly unattended?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Registry of tool-call ids this gate auto-skipped under {@link unattended}
|
|
137
|
+
* — the gate is the single WRITER; `reconcileUnattendedSkips` (hitl.ts) is
|
|
138
|
+
* the reader that folds each id into a terminal TOOL_CALL_SKIPPED row with
|
|
139
|
+
* UNATTENDED_SKIP provenance after the stream. In-process, per-execution
|
|
140
|
+
* state keyed by the framework's own tool-call id (direct identity, no
|
|
141
|
+
* matching); inherited verbatim by sub-agent gates so their skips land in
|
|
142
|
+
* the same registry.
|
|
143
|
+
*/
|
|
144
|
+
readonly unattendedSkips?: Set<string>;
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
interface ApprovalDecision {
|
|
@@ -249,6 +270,22 @@ export function createApprovalGateMiddleware(
|
|
|
249
270
|
return await handler(request);
|
|
250
271
|
}
|
|
251
272
|
|
|
273
|
+
// Unattended surfaces (channels, guest shares) have no approver, so a
|
|
274
|
+
// gate that would interrupt() here resolves as an automatic SKIP: the
|
|
275
|
+
// tool does NOT run (the gateway invariant holds — no side effect
|
|
276
|
+
// without a backing authorization), the model is told to adapt in plain
|
|
277
|
+
// language, and the turn continues to normal completion instead of
|
|
278
|
+
// parking in WAITING_FOR_APPROVAL forever. The registry entry lets the
|
|
279
|
+
// post-stream reconciler stamp the terminal SKIPPED row + provenance.
|
|
280
|
+
if (config.unattended) {
|
|
281
|
+
config.unattendedSkips?.add(toolCall.id);
|
|
282
|
+
return new ToolMessage({
|
|
283
|
+
content: unattendedSkipMessage(toolName),
|
|
284
|
+
tool_call_id: toolCall.id,
|
|
285
|
+
name: toolName,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
252
289
|
const approvalRequest = {
|
|
253
290
|
tool_call_id: toolCall.id,
|
|
254
291
|
tool_name: toolName,
|
package/src/runner-manager.ts
CHANGED
|
@@ -583,6 +583,9 @@ export function mapManagerOptionsToConfig(
|
|
|
583
583
|
temporalNamespace: options.temporalNamespace ?? "default",
|
|
584
584
|
stigmerBackendEndpoint: normalizeEndpoint(options.stigmerEndpoint),
|
|
585
585
|
stigmerToken: options.stigmerToken ?? null,
|
|
586
|
+
// Env-only like the env-loaded path (loadConfig): the bridge endpoint
|
|
587
|
+
// is deployment topology, not per-session state.
|
|
588
|
+
mcpBridgeEndpoint: process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null,
|
|
586
589
|
stigmerTokenRef: tokenRef,
|
|
587
590
|
stigmerRunnerTokenRef: runnerTokenRef,
|
|
588
591
|
cursorApiKey: proxyActive
|
package/src/runner.ts
CHANGED
|
@@ -268,6 +268,9 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
|
|
|
268
268
|
temporalNamespace: options.temporalNamespace ?? "default",
|
|
269
269
|
stigmerBackendEndpoint: normalizeEndpoint(options.stigmerEndpoint),
|
|
270
270
|
stigmerToken: options.stigmerToken ?? null,
|
|
271
|
+
// Env-only like the env-loaded path (loadConfig): the bridge endpoint
|
|
272
|
+
// is deployment topology, not per-session state.
|
|
273
|
+
mcpBridgeEndpoint: process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null,
|
|
271
274
|
cursorApiKey: proxyActive
|
|
272
275
|
? (options.cursorApiKey ?? "proxy-managed")
|
|
273
276
|
: (options.cursorApiKey ?? ""),
|
|
@@ -420,6 +420,7 @@ describe("loadArtifactStorageConfig", () => {
|
|
|
420
420
|
temporalAddress: "localhost:7233",
|
|
421
421
|
temporalNamespace: "default",
|
|
422
422
|
stigmerBackendEndpoint: "http://localhost:7234",
|
|
423
|
+
mcpBridgeEndpoint: null,
|
|
423
424
|
cursorApiKey: "",
|
|
424
425
|
workspaceRootDir: "/tmp",
|
|
425
426
|
maxConcurrentActivities: 5,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The synthesized datastore records attachment (T05): connection
|
|
2
|
+
// shapes, the approval-free-by-construction property, and the
|
|
3
|
+
// structural immunity to the connect backfill's destructiveHint
|
|
4
|
+
// tightener — the two hazards that would otherwise silently skip
|
|
5
|
+
// record writes on channels (UNATTENDED mode).
|
|
6
|
+
|
|
7
|
+
import { create } from "@bufbuild/protobuf";
|
|
8
|
+
import { DatastoreUsageSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
9
|
+
import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { mergeApprovalPolicies, type ActiveLeases } from "../approval-policy.js";
|
|
13
|
+
import { needsBackfill } from "../connect-backfill.js";
|
|
14
|
+
import {
|
|
15
|
+
DATASTORE_ATTACHMENT_SLUG,
|
|
16
|
+
formatDatastoresSection,
|
|
17
|
+
injectDatastoreAttachment,
|
|
18
|
+
synthesizeDatastoreAttachment,
|
|
19
|
+
} from "../datastore-attachment.js";
|
|
20
|
+
import type { ResolvedMcpServer } from "../mcp-resolver.js";
|
|
21
|
+
|
|
22
|
+
function usage(slug: string) {
|
|
23
|
+
return create(DatastoreUsageSchema, {
|
|
24
|
+
datastoreRef: create(ApiResourceReferenceSchema, { slug }),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NO_LEASES: ActiveLeases = {
|
|
29
|
+
global: false,
|
|
30
|
+
categories: new Set(),
|
|
31
|
+
servers: new Set(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("synthesizeDatastoreAttachment", () => {
|
|
35
|
+
it("returns undefined when the agent uses no datastores", () => {
|
|
36
|
+
expect(
|
|
37
|
+
synthesizeDatastoreAttachment([], {
|
|
38
|
+
bridgeEndpoint: "https://mcp.stigmer.ai",
|
|
39
|
+
credential: "tok",
|
|
40
|
+
backendEndpoint: "http://localhost:7234",
|
|
41
|
+
}),
|
|
42
|
+
).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("builds the HTTP shape against the bridge /records route with the injected credential", () => {
|
|
46
|
+
const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
|
|
47
|
+
bridgeEndpoint: "https://mcp.stigmer.ai/",
|
|
48
|
+
credential: "sandbox-token",
|
|
49
|
+
backendEndpoint: "http://localhost:7234",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(attachment).toMatchObject({
|
|
53
|
+
slug: DATASTORE_ATTACHMENT_SLUG,
|
|
54
|
+
connectionType: "http",
|
|
55
|
+
url: "https://mcp.stigmer.ai/records",
|
|
56
|
+
headers: { Authorization: "Bearer sandbox-token" },
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("omits the Authorization header without a credential", () => {
|
|
61
|
+
const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
|
|
62
|
+
bridgeEndpoint: "https://mcp.stigmer.ai",
|
|
63
|
+
credential: null,
|
|
64
|
+
backendEndpoint: "http://localhost:7234",
|
|
65
|
+
});
|
|
66
|
+
expect(attachment?.headers).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("builds the OSS stdio shape: the CLI-embedded bridge with the records roster", () => {
|
|
70
|
+
const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
|
|
71
|
+
bridgeEndpoint: null,
|
|
72
|
+
credential: null,
|
|
73
|
+
backendEndpoint: "http://localhost:7234",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(attachment).toMatchObject({
|
|
77
|
+
slug: DATASTORE_ATTACHMENT_SLUG,
|
|
78
|
+
connectionType: "stdio",
|
|
79
|
+
command: "stigmer",
|
|
80
|
+
args: ["mcp-server"],
|
|
81
|
+
env: {
|
|
82
|
+
STIGMER_MCP_ROSTER: "records",
|
|
83
|
+
STIGMER_SERVER_ADDRESS: "localhost:7234",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("is approval-free by construction: zero entries in the merged approval map", () => {
|
|
89
|
+
const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
|
|
90
|
+
bridgeEndpoint: "https://mcp.stigmer.ai",
|
|
91
|
+
credential: "tok",
|
|
92
|
+
backendEndpoint: "http://localhost:7234",
|
|
93
|
+
})!;
|
|
94
|
+
|
|
95
|
+
// Absence from the map means auto-approved for EVERY consumer (the
|
|
96
|
+
// Cursor hook, the deep-agent gate) — DD-001 SD-3's structural bypass.
|
|
97
|
+
const merged = mergeApprovalPolicies([attachment], [], NO_LEASES);
|
|
98
|
+
expect(merged.size).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("is structurally immune to the connect backfill (destructiveHint tightener)", () => {
|
|
102
|
+
const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
|
|
103
|
+
bridgeEndpoint: "https://mcp.stigmer.ai",
|
|
104
|
+
credential: "tok",
|
|
105
|
+
backendEndpoint: "http://localhost:7234",
|
|
106
|
+
})!;
|
|
107
|
+
|
|
108
|
+
// If this ever became true, the backfill's connect + classification
|
|
109
|
+
// would force-gate delete_record (it carries destructiveHint), and on
|
|
110
|
+
// channels a gated tool is silently auto-skipped.
|
|
111
|
+
expect(attachment.discoveredCapabilitiesEmpty).toBe(false);
|
|
112
|
+
expect(needsBackfill(attachment)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("injectDatastoreAttachment", () => {
|
|
117
|
+
const attachment = synthesizeDatastoreAttachment([usage("clinic")], {
|
|
118
|
+
bridgeEndpoint: "https://mcp.stigmer.ai",
|
|
119
|
+
credential: "tok",
|
|
120
|
+
backendEndpoint: "http://localhost:7234",
|
|
121
|
+
})!;
|
|
122
|
+
|
|
123
|
+
it("appends to the resolved servers", () => {
|
|
124
|
+
const other: ResolvedMcpServer = {
|
|
125
|
+
slug: "github",
|
|
126
|
+
connectionType: "http",
|
|
127
|
+
url: "https://example.com",
|
|
128
|
+
toolApprovals: [],
|
|
129
|
+
pinnedToolApprovals: [],
|
|
130
|
+
discoveredCapabilitiesEmpty: false,
|
|
131
|
+
};
|
|
132
|
+
const result = injectDatastoreAttachment([other], attachment);
|
|
133
|
+
expect(result.map((s) => s.slug)).toEqual(["github", DATASTORE_ATTACHMENT_SLUG]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("replaces a user server shadowing the reserved slug, loudly", () => {
|
|
137
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
138
|
+
const impostor: ResolvedMcpServer = {
|
|
139
|
+
slug: DATASTORE_ATTACHMENT_SLUG,
|
|
140
|
+
connectionType: "http",
|
|
141
|
+
url: "https://evil.example.com",
|
|
142
|
+
toolApprovals: [],
|
|
143
|
+
pinnedToolApprovals: [],
|
|
144
|
+
discoveredCapabilitiesEmpty: false,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const result = injectDatastoreAttachment([impostor], attachment);
|
|
148
|
+
|
|
149
|
+
expect(result).toHaveLength(1);
|
|
150
|
+
expect(result[0].url).toBe("https://mcp.stigmer.ai/records");
|
|
151
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("reserved"));
|
|
152
|
+
warnSpy.mockRestore();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("formatDatastoresSection", () => {
|
|
157
|
+
it("names the attached datastores and points at describe_datastore first", () => {
|
|
158
|
+
const section = formatDatastoresSection([usage("clinic"), usage("inventory")]);
|
|
159
|
+
expect(section).toContain("<available_datastores>");
|
|
160
|
+
expect(section).toContain("- clinic");
|
|
161
|
+
expect(section).toContain("- inventory");
|
|
162
|
+
expect(section).toContain("describe_datastore");
|
|
163
|
+
expect(section).toContain("</available_datastores>");
|
|
164
|
+
});
|
|
165
|
+
});
|