@stigmer/cli 3.12.3 → 3.12.5
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/commands/draft.js +4 -2
- package/commands/draft.js.map +1 -1
- package/commands/list.js +7 -15
- package/commands/list.js.map +1 -1
- package/commands/run.js +4 -2
- package/commands/run.js.map +1 -1
- package/commands/seedpack.d.ts.map +1 -1
- package/commands/seedpack.js +27 -5
- package/commands/seedpack.js.map +1 -1
- package/local/seedpack/apply.d.ts +32 -3
- package/local/seedpack/apply.d.ts.map +1 -1
- package/local/seedpack/apply.js +59 -8
- package/local/seedpack/apply.js.map +1 -1
- package/package.json +5 -5
- package/registry/verb-support.d.ts.map +1 -1
- package/registry/verb-support.js +6 -3
- package/registry/verb-support.js.map +1 -1
- package/resources/apply/apply.d.ts.map +1 -1
- package/resources/apply/apply.js +10 -1
- package/resources/apply/apply.js.map +1 -1
- package/resources/connect/connect.d.ts +7 -0
- package/resources/connect/connect.d.ts.map +1 -1
- package/resources/connect/connect.js +47 -30
- package/resources/connect/connect.js.map +1 -1
- package/resources/connect/display.d.ts.map +1 -1
- package/resources/connect/display.js +3 -0
- package/resources/connect/display.js.map +1 -1
- package/resources/list.d.ts.map +1 -1
- package/resources/list.js +39 -9
- package/resources/list.js.map +1 -1
- package/resources/run/prepare.d.ts +12 -1
- package/resources/run/prepare.d.ts.map +1 -1
- package/resources/run/prepare.js +27 -2
- package/resources/run/prepare.js.map +1 -1
- package/resources/session.d.ts +0 -8
- package/resources/session.d.ts.map +1 -1
- package/resources/session.js +6 -33
- package/resources/session.js.map +1 -1
- package/resources/skill.d.ts +0 -10
- package/resources/skill.d.ts.map +1 -1
- package/resources/skill.js +8 -59
- package/resources/skill.js.map +1 -1
- package/resources/stream/events.d.ts +1 -1
- package/resources/stream/events.d.ts.map +1 -1
- package/resources/stream/events.js +8 -6
- package/resources/stream/events.js.map +1 -1
- package/resources/stream/render-plaintext.d.ts.map +1 -1
- package/resources/stream/render-plaintext.js +19 -5
- package/resources/stream/render-plaintext.js.map +1 -1
- package/resources/task-configs.d.ts +15 -0
- package/resources/task-configs.d.ts.map +1 -0
- package/resources/task-configs.js +135 -0
- package/resources/task-configs.js.map +1 -0
- package/resources/validate.d.ts.map +1 -1
- package/resources/validate.js +8 -1
- package/resources/validate.js.map +1 -1
- package/src/commands/draft.ts +4 -2
- package/src/commands/list.ts +7 -17
- package/src/commands/run.ts +4 -2
- package/src/commands/seedpack.ts +27 -5
- package/src/local/seedpack/apply.test.ts +127 -6
- package/src/local/seedpack/apply.ts +69 -9
- package/src/registry/registry.test.ts +50 -8
- package/src/registry/verb-support.ts +6 -3
- package/src/resources/apply/apply.ts +11 -1
- package/src/resources/connect/connect.ts +70 -44
- package/src/resources/connect/display.ts +3 -0
- package/src/resources/list.ts +40 -9
- package/src/resources/read-completion.integration.test.ts +14 -4
- package/src/resources/run/prepare.test.ts +80 -0
- package/src/resources/run/prepare.ts +41 -1
- package/src/resources/session.ts +6 -39
- package/src/resources/skill.test.ts +5 -77
- package/src/resources/skill.ts +8 -67
- package/src/resources/stream/events.ts +9 -7
- package/src/resources/stream/render-plaintext.test.ts +51 -0
- package/src/resources/stream/render-plaintext.ts +24 -6
- package/src/resources/task-configs.test.ts +98 -0
- package/src/resources/task-configs.ts +150 -0
- package/src/resources/validate.test.ts +26 -0
- package/src/resources/validate.ts +10 -2
- package/src/resources/session.test.ts +0 -48
package/src/resources/skill.ts
CHANGED
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
9
|
import { join, relative, resolve } from "node:path";
|
|
10
10
|
import { create } from "@bufbuild/protobuf";
|
|
11
|
-
import { Code, ConnectError } from "@connectrpc/connect";
|
|
12
11
|
import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
13
|
-
import
|
|
14
|
-
import { CreateSkillArtifactUploadUrlRequestSchema, PushSkillRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
|
|
12
|
+
import { PushSkillRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
|
|
15
13
|
import { GitProvenanceSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/status_pb";
|
|
16
14
|
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
17
15
|
import { UpdateVisibilityInputSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
@@ -322,67 +320,10 @@ export function analyzeDryRun(dir: string, options: IgnoreOptions): DryRunAnalys
|
|
|
322
320
|
return { stats, patternSources, sampleIgnored, sampleIncluded };
|
|
323
321
|
}
|
|
324
322
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
* Mirrors the Go SDK's maxInlineArtifactBytes.
|
|
330
|
-
*/
|
|
331
|
-
const MAX_INLINE_ARTIFACT_BYTES = 10 * 1024 * 1024 - 64 * 1024;
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Push, routing the artifact by size (#675): small artifacts travel inline
|
|
335
|
-
* in the request (one round trip, unchanged behavior); larger ones are
|
|
336
|
-
* staged over HTTP via createArtifactUploadUrl — a capability URL, so no
|
|
337
|
-
* auth header — and pushed by reference. The 100MB skill limit therefore
|
|
338
|
-
* no longer collides with the 10MB gRPC message cap.
|
|
339
|
-
*/
|
|
340
|
-
export async function pushRouted(client: Stigmer, request: PushSkillRequest): Promise<Skill> {
|
|
341
|
-
if (request.artifact.length <= MAX_INLINE_ARTIFACT_BYTES) {
|
|
342
|
-
return client.skill.push(request);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
let minted;
|
|
346
|
-
try {
|
|
347
|
-
minted = await client.skill.createArtifactUploadUrl(
|
|
348
|
-
create(CreateSkillArtifactUploadUrlRequestSchema, {
|
|
349
|
-
org: request.org,
|
|
350
|
-
sizeBytes: BigInt(request.artifact.length),
|
|
351
|
-
}),
|
|
352
|
-
);
|
|
353
|
-
} catch (err) {
|
|
354
|
-
if (err instanceof ConnectError && err.code === Code.Unimplemented) {
|
|
355
|
-
// Pre-transfer-lane server: an artifact this size physically cannot
|
|
356
|
-
// travel inline. Say so instead of surfacing the raw transport error.
|
|
357
|
-
throw new UsageError(
|
|
358
|
-
`skill artifact is ${formatBytes(request.artifact.length)}, above the ~10MB gRPC message cap, ` +
|
|
359
|
-
"and this server does not support the HTTP artifact transfer lane.\n\n" +
|
|
360
|
-
"Upgrade stigmer-server to push skills of this size.",
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
throw err;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const resp = await fetch(minted.url, {
|
|
367
|
-
method: "PUT",
|
|
368
|
-
// Buffer keeps undici's BodyInit typing happy where a bare Uint8Array
|
|
369
|
-
// view does not (Node-only code path, so Buffer is always available).
|
|
370
|
-
body: Buffer.from(request.artifact),
|
|
371
|
-
headers: { "content-type": "application/zip" },
|
|
372
|
-
});
|
|
373
|
-
if (!resp.ok) {
|
|
374
|
-
const detail = (await resp.text().catch(() => "")).slice(0, 512).trim();
|
|
375
|
-
throw new Error(`skill artifact upload rejected with HTTP ${resp.status}${detail === "" ? "" : `: ${detail}`}`);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Same request, artifact traveling by reference instead of by value.
|
|
379
|
-
const byRef = create(PushSkillRequestSchema, {
|
|
380
|
-
...request,
|
|
381
|
-
artifact: new Uint8Array(0),
|
|
382
|
-
artifactUploadRef: minted.artifactUploadRef,
|
|
383
|
-
});
|
|
384
|
-
return client.skill.push(byRef);
|
|
385
|
-
}
|
|
323
|
+
// Size-routed push (#675) moved down into the SDK (stigmer#701): the
|
|
324
|
+
// Stigmer client's `skill.push` routes large artifacts over the HTTP
|
|
325
|
+
// transfer lane itself, so the CLI — like every other SDK consumer — just
|
|
326
|
+
// calls push. See sdk/typescript/src/skill.ts (RoutedSkillClient).
|
|
386
327
|
|
|
387
328
|
/** Push a skill from a local directory. Git provenance is auto-detected. */
|
|
388
329
|
export async function pushSkill(
|
|
@@ -410,7 +351,7 @@ export async function pushSkill(
|
|
|
410
351
|
message,
|
|
411
352
|
gitProvenance: provenance,
|
|
412
353
|
});
|
|
413
|
-
const response = await
|
|
354
|
+
const response = await client.skill.push(request);
|
|
414
355
|
const applied = await applyDeclaredVisibility(client, response, visibility);
|
|
415
356
|
return toResult(applied, name, message, stats.totalSize, visibility);
|
|
416
357
|
}
|
|
@@ -452,7 +393,7 @@ export async function pushSkillFromClone(
|
|
|
452
393
|
message: params.message,
|
|
453
394
|
gitProvenance: provenance,
|
|
454
395
|
});
|
|
455
|
-
const response = await
|
|
396
|
+
const response = await client.skill.push(request);
|
|
456
397
|
const applied = await applyDeclaredVisibility(client, response, visibility);
|
|
457
398
|
return toResult(applied, name, params.message, stats.totalSize, visibility);
|
|
458
399
|
}
|
|
@@ -540,7 +481,7 @@ export async function pushSkillFromArchive(
|
|
|
540
481
|
});
|
|
541
482
|
// Size-routed like every other push path (#675): pre-packaged archives are
|
|
542
483
|
// the release-pipeline lane, exactly where content-heavy skills come from.
|
|
543
|
-
const response = await
|
|
484
|
+
const response = await client.skill.push(request);
|
|
544
485
|
const applied = await applyDeclaredVisibility(client, response, archive.meta.visibility);
|
|
545
486
|
return toResult(applied, archive.meta.name, message, archive.totalSize, archive.meta.visibility);
|
|
546
487
|
}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
// The sealed event model for the headless snapshot differ.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Inherited from the retired Go CLI's pkg/executiontui.Event family (deleted
|
|
4
|
+
// with the TypeScript migration, PR #203). The differ projects each
|
|
5
|
+
// AgentExecution snapshot into a sequence of these discrete events; the
|
|
5
6
|
// NDJSON and plaintext renderers consume them. The Ink TTY path does NOT use
|
|
6
|
-
// this model — it renders from full snapshots via @stigmer/
|
|
7
|
+
// this model — it renders from full snapshots via @stigmer/ink.
|
|
7
8
|
//
|
|
8
|
-
// `kind` is the discriminant. The NDJSON renderer
|
|
9
|
-
// wire taxonomy (run_stream_json.go)
|
|
10
|
-
//
|
|
9
|
+
// `kind` is the discriminant. The NDJSON renderer preserves the retired Go
|
|
10
|
+
// CLI's exact wire taxonomy (its run_stream_json.go) — that output is a
|
|
11
|
+
// machine contract consumers may parse — so kinds are deliberately 1:1 with
|
|
12
|
+
// the original event types rather than the wire strings.
|
|
11
13
|
|
|
12
14
|
import type { JsonObject } from "@bufbuild/protobuf";
|
|
13
15
|
|
|
14
|
-
/** A tool call projected for rendering.
|
|
16
|
+
/** A tool call projected for rendering. Shape inherited from the retired Go CLI's toolrender.ToolCallInfo. */
|
|
15
17
|
export interface ToolCallInfo {
|
|
16
18
|
readonly id: string;
|
|
17
19
|
readonly name: string;
|
|
@@ -39,6 +39,57 @@ describe("PlaintextRenderer", () => {
|
|
|
39
39
|
expect(status).toContain("✗ shell: exploded");
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it("titles shell status lines with the model-authored intent when present (stigmer#276)", () => {
|
|
43
|
+
const { status } = render([
|
|
44
|
+
{
|
|
45
|
+
kind: "toolRunning",
|
|
46
|
+
toolCallId: "t1",
|
|
47
|
+
subAgentId: "",
|
|
48
|
+
toolCall: toolInfo({
|
|
49
|
+
name: "execute",
|
|
50
|
+
status: "running",
|
|
51
|
+
args: { command: "npx vitest run src/parser", description: "Run unit tests for the parser" },
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
kind: "toolCompleted",
|
|
56
|
+
toolCallId: "t1",
|
|
57
|
+
subAgentId: "",
|
|
58
|
+
toolCall: toolInfo({
|
|
59
|
+
name: "execute",
|
|
60
|
+
args: { command: "npx vitest run src/parser", description: "Run unit tests for the parser" },
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
]);
|
|
64
|
+
expect(status).toContain("⠋ Run unit tests for the parser");
|
|
65
|
+
expect(status).toContain("✓ Run unit tests for the parser");
|
|
66
|
+
expect(status).not.toContain("execute");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps the raw tool name when there is no intent, and never titles non-shell tools from description", () => {
|
|
70
|
+
const { status } = render([
|
|
71
|
+
{
|
|
72
|
+
kind: "toolCompleted",
|
|
73
|
+
toolCallId: "t1",
|
|
74
|
+
subAgentId: "",
|
|
75
|
+
toolCall: toolInfo({ name: "execute", args: { command: "ls" } }),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
kind: "toolCompleted",
|
|
79
|
+
toolCallId: "t2",
|
|
80
|
+
subAgentId: "",
|
|
81
|
+
toolCall: toolInfo({
|
|
82
|
+
name: "task",
|
|
83
|
+
args: { description: "general-purpose research", prompt: "Find the docs" },
|
|
84
|
+
}),
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
// A task tool's description is the sub-agent subject, never a row title.
|
|
88
|
+
expect(status).toContain("✓ execute");
|
|
89
|
+
expect(status).toContain("✓ task");
|
|
90
|
+
expect(status).not.toContain("general-purpose research");
|
|
91
|
+
});
|
|
92
|
+
|
|
42
93
|
it("renders an interrupted tool with the neutral glyph, not a checkmark (issue #207)", () => {
|
|
43
94
|
const { status } = render([
|
|
44
95
|
{ kind: "toolInterrupted", toolCallId: "t1", subAgentId: "", toolCall: toolInfo({ name: "shell", status: "interrupted" }) },
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Plaintext headless renderer for non-TTY output (piped stdout).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Inherited from the retired Go CLI's streamAgentPlainText: clean AI text to
|
|
4
4
|
// the data sink (stdout), terse tool/status lines to the status sink (stderr).
|
|
5
5
|
// No ANSI, no frames. Approvals are auto-skipped — there is no keyboard here;
|
|
6
6
|
// use --json for a machine-readable non-TTY stream. Phase/done/stream-error are
|
|
7
7
|
// not rendered: the driver returns the outcome and the command surfaces it.
|
|
8
8
|
|
|
9
9
|
import { ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
10
|
-
import
|
|
10
|
+
import { resolveToolKindByName, shellIntentFromArgs } from "@stigmer/sdk";
|
|
11
|
+
import type { ApprovalNeededEvent, StreamEvent, ToolCallInfo } from "./events.js";
|
|
11
12
|
import type { HeadlessRenderer } from "./headless.js";
|
|
12
13
|
import type { LineWriter } from "./render-ndjson.js";
|
|
13
14
|
|
|
@@ -40,17 +41,17 @@ export class PlaintextRenderer implements HeadlessRenderer {
|
|
|
40
41
|
status.write(`\n> ${event.content}\n\n`);
|
|
41
42
|
return;
|
|
42
43
|
case "toolRunning":
|
|
43
|
-
status.write(` ⠋ ${event.toolCall
|
|
44
|
+
status.write(` ⠋ ${toolLine(event.toolCall)}\n`);
|
|
44
45
|
return;
|
|
45
46
|
case "toolCompleted":
|
|
46
47
|
status.write(
|
|
47
48
|
event.toolCall.error !== ""
|
|
48
|
-
? ` ✗ ${event.toolCall
|
|
49
|
-
: ` ✓ ${event.toolCall
|
|
49
|
+
? ` ✗ ${toolLine(event.toolCall)}: ${truncate(event.toolCall.error, MAX_ERROR_LEN)}\n`
|
|
50
|
+
: ` ✓ ${toolLine(event.toolCall)}\n`,
|
|
50
51
|
);
|
|
51
52
|
return;
|
|
52
53
|
case "toolInterrupted":
|
|
53
|
-
status.write(` ⊘ ${event.toolCall
|
|
54
|
+
status.write(` ⊘ ${toolLine(event.toolCall)} (interrupted)\n`);
|
|
54
55
|
return;
|
|
55
56
|
case "approvalNeeded":
|
|
56
57
|
status.write(` ⚠ Approval needed: ${event.toolName} (auto-skipped in non-TTY mode)\n`);
|
|
@@ -82,6 +83,23 @@ export class PlaintextRenderer implements HeadlessRenderer {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The status-line label for a tool call: the model-authored intent phrase
|
|
88
|
+
* when the shell call carries one (stigmer#276) — the same convention that
|
|
89
|
+
* titles rows in the web console and the Ink TTY path — otherwise the raw
|
|
90
|
+
* tool name. Kind is resolved by name only: the snapshot projection carries
|
|
91
|
+
* neither `tool_kind` nor `mcp_server_slug`, and MCP tools never carry an
|
|
92
|
+
* intent (the fixture pins them to null), so name-lookup is sufficient here.
|
|
93
|
+
* NDJSON is deliberately untouched — it is the machine contract.
|
|
94
|
+
*/
|
|
95
|
+
function toolLine(toolCall: ToolCallInfo): string {
|
|
96
|
+
const intent = shellIntentFromArgs(
|
|
97
|
+
resolveToolKindByName(toolCall.name),
|
|
98
|
+
toolCall.args,
|
|
99
|
+
);
|
|
100
|
+
return intent ?? toolCall.name;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
// Trim and ellipsize. Mirrors Go's truncate.
|
|
86
104
|
function truncate(value: string, maxLen: number): string {
|
|
87
105
|
const trimmed = value.trim();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
|
2
|
+
import { type Workflow, WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
3
|
+
import {
|
|
4
|
+
WorkflowTaskKind,
|
|
5
|
+
WorkflowTaskKindSchema,
|
|
6
|
+
} from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import { decodeWorkflowTaskConfigs, TASK_CONFIG_SCHEMAS } from "./task-configs.js";
|
|
9
|
+
|
|
10
|
+
function makeWorkflow(tasks: JsonValue): Workflow {
|
|
11
|
+
return fromJson(WorkflowSchema, {
|
|
12
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
13
|
+
kind: "Workflow",
|
|
14
|
+
metadata: { name: "decode-test", org: "acme" },
|
|
15
|
+
spec: {
|
|
16
|
+
description: "task-config decode fixture",
|
|
17
|
+
document: { dsl: "1.0.0", namespace: "acme", name: "decode-test", version: "1.0.0" },
|
|
18
|
+
tasks,
|
|
19
|
+
},
|
|
20
|
+
}) as Workflow;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("TASK_CONFIG_SCHEMAS", () => {
|
|
24
|
+
// The stigmer/stigmer#353 drift class: hold the map and the proto enum to
|
|
25
|
+
// strict bidirectional equality, so a new task kind cannot ship without a
|
|
26
|
+
// CLI schema binding and a retired kind cannot linger in the map. The
|
|
27
|
+
// server-side twin of this promise is unmarshalTaskConfig's kind switch.
|
|
28
|
+
it("covers every WorkflowTaskKind exactly (bidirectional)", () => {
|
|
29
|
+
const declaredKinds = WorkflowTaskKindSchema.values
|
|
30
|
+
.map((v) => v.number as WorkflowTaskKind)
|
|
31
|
+
.filter((n) => n !== WorkflowTaskKind.workflow_task_kind_unspecified)
|
|
32
|
+
.sort((a, b) => a - b);
|
|
33
|
+
const mappedKinds = [...TASK_CONFIG_SCHEMAS.keys()].sort((a, b) => a - b);
|
|
34
|
+
expect(mappedKinds).toEqual(declaredKinds);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("decodeWorkflowTaskConfigs", () => {
|
|
39
|
+
it("rejects the string duration form a real apply rejects (the #778 repro)", () => {
|
|
40
|
+
const workflow = makeWorkflow([
|
|
41
|
+
{ name: "conditional_wait", kind: "wait", task_config: { duration: "5s" } },
|
|
42
|
+
]);
|
|
43
|
+
expect(() => decodeWorkflowTaskConfigs(workflow)).toThrow(
|
|
44
|
+
/task 'conditional_wait' \(wait\): invalid task_config/,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("accepts the typed duration object", () => {
|
|
49
|
+
const workflow = makeWorkflow([
|
|
50
|
+
{ name: "cooldown", kind: "wait", task_config: { duration: { seconds: 5 } } },
|
|
51
|
+
]);
|
|
52
|
+
expect(() => decodeWorkflowTaskConfigs(workflow)).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects unknown task_config fields, mirroring the server's strict decode", () => {
|
|
56
|
+
const workflow = makeWorkflow([
|
|
57
|
+
{
|
|
58
|
+
name: "gate",
|
|
59
|
+
kind: "human_input",
|
|
60
|
+
task_config: { prompt: "Approve?", escalation_task: "somewhere" },
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
expect(() => decodeWorkflowTaskConfigs(workflow)).toThrow(/gate.*human_input.*task_config/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rejects values outside an enum's membership", () => {
|
|
67
|
+
const workflow = makeWorkflow([
|
|
68
|
+
{
|
|
69
|
+
name: "gate",
|
|
70
|
+
kind: "human_input",
|
|
71
|
+
task_config: { prompt: "Approve?", timeout: 60, on_timeout: "sometimes" },
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
expect(() => decodeWorkflowTaskConfigs(workflow)).toThrow(/gate.*invalid task_config/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("accepts the agent_call DSL shorthands a real apply accepts (never stricter)", () => {
|
|
78
|
+
const workflow = makeWorkflow([
|
|
79
|
+
{
|
|
80
|
+
name: "review",
|
|
81
|
+
kind: "agent_call",
|
|
82
|
+
task_config: {
|
|
83
|
+
agent: "acme/code-reviewer",
|
|
84
|
+
message: "Review ${ $context.pr.diff }",
|
|
85
|
+
harness: "cursor",
|
|
86
|
+
run_config: { model_name: "composer-2.5", service_tier: "fast" },
|
|
87
|
+
environment_refs: [{ slug: "shared-secrets" }],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
expect(() => decodeWorkflowTaskConfigs(workflow)).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("skips tasks without a task_config (protovalidate territory, not decode truth)", () => {
|
|
95
|
+
const workflow = makeWorkflow([{ name: "bare", kind: "set_vars" }]);
|
|
96
|
+
expect(() => decodeWorkflowTaskConfigs(workflow)).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Typed decode of workflow task_config blocks — the offline twin of the
|
|
2
|
+
// server's `unmarshalTaskConfig` (backend/services/stigmer-server/pkg/domain/
|
|
3
|
+
// workflow/converter/unmarshal.go).
|
|
4
|
+
//
|
|
5
|
+
// The Workflow proto models `task_config` as an open google.protobuf.Struct
|
|
6
|
+
// discriminated by `kind`, so the top-level schema decode accepts ANY config
|
|
7
|
+
// payload; a real apply then decodes it into the kind's typed message and
|
|
8
|
+
// rejects what does not fit. Before this module, `stigmer validate` and
|
|
9
|
+
// `apply --dry-run` reported "valid" for configs a real apply refuses
|
|
10
|
+
// (stigmer/stigmer#778).
|
|
11
|
+
//
|
|
12
|
+
// Scope is decode-truth only: field shapes, types, enum membership, unknown
|
|
13
|
+
// fields. Semantic validation (cross-task references, expression namespaces,
|
|
14
|
+
// model registry, protovalidate required-field rules) remains the server's
|
|
15
|
+
// authority via `validateSpec`.
|
|
16
|
+
|
|
17
|
+
import { type DescMessage, fromJson, type JsonValue } from "@bufbuild/protobuf";
|
|
18
|
+
import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
19
|
+
import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
|
|
20
|
+
import { AgentCallTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/agent_call_pb";
|
|
21
|
+
import { CallActivityTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/call_activity_pb";
|
|
22
|
+
import { EmitEventTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/emit_event_pb";
|
|
23
|
+
import { EvalTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/eval_pb";
|
|
24
|
+
import { ForTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/for_pb";
|
|
25
|
+
import { ForkTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/fork_pb";
|
|
26
|
+
import { GrpcCallTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/grpc_call_pb";
|
|
27
|
+
import { HttpCallTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/http_call_pb";
|
|
28
|
+
import { HumanInputTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/human_input_pb";
|
|
29
|
+
import { ListenTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/listen_pb";
|
|
30
|
+
import { LlmCallTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/llm_call_pb";
|
|
31
|
+
import { NotificationTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/notification_pb";
|
|
32
|
+
import { RaiseTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/raise_pb";
|
|
33
|
+
import { RunTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/run_pb";
|
|
34
|
+
import { SetTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/set_pb";
|
|
35
|
+
import { SwitchTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/switch_pb";
|
|
36
|
+
import { TransformTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/transform_pb";
|
|
37
|
+
import { TryTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/try_pb";
|
|
38
|
+
import { ValidateTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/validate_pb";
|
|
39
|
+
import { WaitTaskConfigSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/tasks/wait_pb";
|
|
40
|
+
|
|
41
|
+
// Exported for the drift test (task-configs.test.ts), which holds this map and
|
|
42
|
+
// the WorkflowTaskKind enum descriptor to strict bidirectional equality — the
|
|
43
|
+
// stigmer/stigmer#353 drift class: a new task kind cannot ship without a CLI
|
|
44
|
+
// schema binding, and a retired kind cannot linger here. The entries mirror
|
|
45
|
+
// the server's `unmarshalTaskConfig` kind switch one-for-one.
|
|
46
|
+
export const TASK_CONFIG_SCHEMAS: ReadonlyMap<WorkflowTaskKind, DescMessage> = new Map<
|
|
47
|
+
WorkflowTaskKind,
|
|
48
|
+
DescMessage
|
|
49
|
+
>([
|
|
50
|
+
[WorkflowTaskKind.set_vars, SetTaskConfigSchema],
|
|
51
|
+
[WorkflowTaskKind.http_call, HttpCallTaskConfigSchema],
|
|
52
|
+
[WorkflowTaskKind.grpc_call, GrpcCallTaskConfigSchema],
|
|
53
|
+
[WorkflowTaskKind.switch_case, SwitchTaskConfigSchema],
|
|
54
|
+
[WorkflowTaskKind.for_each, ForTaskConfigSchema],
|
|
55
|
+
[WorkflowTaskKind.fork, ForkTaskConfigSchema],
|
|
56
|
+
[WorkflowTaskKind.try_catch, TryTaskConfigSchema],
|
|
57
|
+
[WorkflowTaskKind.listen, ListenTaskConfigSchema],
|
|
58
|
+
[WorkflowTaskKind.wait, WaitTaskConfigSchema],
|
|
59
|
+
[WorkflowTaskKind.activity_call, CallActivityTaskConfigSchema],
|
|
60
|
+
[WorkflowTaskKind.raise_error, RaiseTaskConfigSchema],
|
|
61
|
+
[WorkflowTaskKind.run_workflow, RunTaskConfigSchema],
|
|
62
|
+
[WorkflowTaskKind.agent_call, AgentCallTaskConfigSchema],
|
|
63
|
+
[WorkflowTaskKind.llm_call, LlmCallTaskConfigSchema],
|
|
64
|
+
[WorkflowTaskKind.transform, TransformTaskConfigSchema],
|
|
65
|
+
[WorkflowTaskKind.human_input, HumanInputTaskConfigSchema],
|
|
66
|
+
[WorkflowTaskKind.validate, ValidateTaskConfigSchema],
|
|
67
|
+
[WorkflowTaskKind.emit_event, EmitEventTaskConfigSchema],
|
|
68
|
+
[WorkflowTaskKind.notification, NotificationTaskConfigSchema],
|
|
69
|
+
[WorkflowTaskKind.eval, EvalTaskConfigSchema],
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Decodes every task's `task_config` into its kind's typed proto message,
|
|
74
|
+
* throwing on the first config a real apply would reject. Runs on the decoded
|
|
75
|
+
* Workflow message (after the top-level schema decode), so snake_case and
|
|
76
|
+
* json-name spellings are already normalized.
|
|
77
|
+
*
|
|
78
|
+
* A task with no `task_config` is skipped: its absence is a protovalidate
|
|
79
|
+
* required-field violation, which is the server's layer, not decode-truth.
|
|
80
|
+
*/
|
|
81
|
+
export function decodeWorkflowTaskConfigs(workflow: Workflow): void {
|
|
82
|
+
for (const task of workflow.spec?.tasks ?? []) {
|
|
83
|
+
if (task.taskConfig === undefined) continue;
|
|
84
|
+
const schema = TASK_CONFIG_SCHEMAS.get(task.kind);
|
|
85
|
+
if (schema === undefined) continue; // unspecified/unknown kind — the server's layer reports it
|
|
86
|
+
|
|
87
|
+
// protobuf-es represents google.protobuf.Struct fields as plain JSON, so
|
|
88
|
+
// the Struct's content feeds the per-kind decode directly.
|
|
89
|
+
const config = normalizeTaskConfigShorthands(task.kind, task.taskConfig);
|
|
90
|
+
try {
|
|
91
|
+
fromJson(schema, config, { ignoreUnknownFields: false });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`task '${task.name}' (${WorkflowTaskKind[task.kind]}): invalid task_config: ${(err as Error).message}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Rewrites user-friendly DSL forms to the shapes the proto decode expects —
|
|
102
|
+
* the twin of the server's `normalizeEnumShorthands` (unmarshal.go): agent_call
|
|
103
|
+
* harness shorthands ("cursor" → "HARNESS_CURSOR"), run_config service-tier
|
|
104
|
+
* shorthands ("fast" → "SERVICE_TIER_FAST"), and the environment_refs kind
|
|
105
|
+
* default (an omitted kind means environment). Without this twin the offline
|
|
106
|
+
* decode would be STRICTER than a real apply and fail valid manifests.
|
|
107
|
+
*/
|
|
108
|
+
function normalizeTaskConfigShorthands(kind: WorkflowTaskKind, config: JsonValue): JsonValue {
|
|
109
|
+
if (kind !== WorkflowTaskKind.agent_call || !isJsonObject(config)) {
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const normalized: Record<string, JsonValue> = { ...config };
|
|
114
|
+
|
|
115
|
+
if (typeof normalized.harness === "string") {
|
|
116
|
+
const harness = HARNESS_SHORTHANDS[normalized.harness.toLowerCase()];
|
|
117
|
+
if (harness !== undefined) normalized.harness = harness;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isJsonObject(normalized.run_config)) {
|
|
121
|
+
const runConfig: Record<string, JsonValue> = { ...normalized.run_config };
|
|
122
|
+
if (typeof runConfig.service_tier === "string") {
|
|
123
|
+
const tier = SERVICE_TIER_SHORTHANDS[runConfig.service_tier.toLowerCase()];
|
|
124
|
+
if (tier !== undefined) runConfig.service_tier = tier;
|
|
125
|
+
}
|
|
126
|
+
normalized.run_config = runConfig;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (Array.isArray(normalized.environment_refs)) {
|
|
130
|
+
normalized.environment_refs = normalized.environment_refs.map((ref) =>
|
|
131
|
+
isJsonObject(ref) && ref.kind === undefined ? { ...ref, kind: "environment" } : ref,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return normalized;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const HARNESS_SHORTHANDS: Record<string, string> = {
|
|
139
|
+
native: "HARNESS_NATIVE",
|
|
140
|
+
cursor: "HARNESS_CURSOR",
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const SERVICE_TIER_SHORTHANDS: Record<string, string> = {
|
|
144
|
+
standard: "SERVICE_TIER_STANDARD",
|
|
145
|
+
fast: "SERVICE_TIER_FAST",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
function isJsonObject(value: JsonValue | undefined): value is Record<string, JsonValue> {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsonValue } from "@bufbuild/protobuf";
|
|
1
2
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
2
3
|
import { describe, expect, it } from "vitest";
|
|
3
4
|
import { schemaForValidate, validateDocument } from "./validate.js";
|
|
@@ -51,4 +52,29 @@ describe("validateDocument", () => {
|
|
|
51
52
|
}),
|
|
52
53
|
).toThrow();
|
|
53
54
|
});
|
|
55
|
+
|
|
56
|
+
// The stigmer/stigmer#778 wiring pin: task_config blocks are typed per kind,
|
|
57
|
+
// so validate rejects what a real apply rejects instead of waving any Struct
|
|
58
|
+
// payload through. Decode behavior itself is pinned in task-configs.test.ts.
|
|
59
|
+
it("decodes workflow task_config blocks per kind", () => {
|
|
60
|
+
const workflowSchema = schemaForValidate(ApiResourceKind.workflow);
|
|
61
|
+
if (workflowSchema === undefined) throw new Error("workflow schema unavailable");
|
|
62
|
+
|
|
63
|
+
const workflowDoc = (taskConfig: JsonValue): JsonValue => ({
|
|
64
|
+
apiVersion: "agentic.stigmer.ai/v1",
|
|
65
|
+
kind: "Workflow",
|
|
66
|
+
metadata: { name: "wf", org: "acme" },
|
|
67
|
+
spec: {
|
|
68
|
+
document: { dsl: "1.0.0", namespace: "acme", name: "wf", version: "1.0.0" },
|
|
69
|
+
tasks: [{ name: "conditional_wait", kind: "wait", task_config: taskConfig }],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(() => validateDocument(workflowSchema, workflowDoc({ duration: "5s" }))).toThrow(
|
|
74
|
+
/conditional_wait.*invalid task_config/,
|
|
75
|
+
);
|
|
76
|
+
expect(() =>
|
|
77
|
+
validateDocument(workflowSchema, workflowDoc({ duration: { seconds: 5 } })),
|
|
78
|
+
).not.toThrow();
|
|
79
|
+
});
|
|
54
80
|
});
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
import { type DescMessage, fromJson, type JsonValue } from "@bufbuild/protobuf";
|
|
9
9
|
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
10
10
|
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
11
|
-
import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
11
|
+
import { type Workflow, WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
12
12
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
13
13
|
import { ProjectSchema } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/api_pb";
|
|
14
|
+
import { decodeWorkflowTaskConfigs } from "./task-configs.js";
|
|
14
15
|
|
|
15
16
|
// Exported for the verb/dispatch conformance suite (registry/registry.test.ts),
|
|
16
17
|
// which holds this map and the matrix's Verb.Validate promises to strict
|
|
@@ -31,5 +32,12 @@ export function schemaForValidate(kind: ApiResourceKind): DescMessage | undefine
|
|
|
31
32
|
export function validateDocument(schema: DescMessage, document: JsonValue): void {
|
|
32
33
|
// ignoreUnknownFields mirrors the server's lenient unmarshal: forward-compat
|
|
33
34
|
// fields a newer server adds should not fail a slightly older CLI.
|
|
34
|
-
fromJson(schema, document, { ignoreUnknownFields: true });
|
|
35
|
+
const message = fromJson(schema, document, { ignoreUnknownFields: true });
|
|
36
|
+
|
|
37
|
+
// Workflows carry per-kind typed configs inside an open Struct that the
|
|
38
|
+
// top-level decode cannot see into — decode them too, so validate stops
|
|
39
|
+
// passing configs a real apply rejects (stigmer/stigmer#778).
|
|
40
|
+
if (schema === WorkflowSchema) {
|
|
41
|
+
decodeWorkflowTaskConfigs(message as Workflow);
|
|
42
|
+
}
|
|
35
43
|
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { create } from "@bufbuild/protobuf";
|
|
2
|
-
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
3
|
-
import { SessionListSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/io_pb";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { isSessionAlias, renderSessionList } from "./session.js";
|
|
6
|
-
|
|
7
|
-
describe("isSessionAlias", () => {
|
|
8
|
-
it.each([
|
|
9
|
-
["session", true],
|
|
10
|
-
["sessions", true],
|
|
11
|
-
[" Session ", true],
|
|
12
|
-
["execution", false],
|
|
13
|
-
["agent", false],
|
|
14
|
-
])("%j -> %s", (type, expected) => {
|
|
15
|
-
expect(isSessionAlias(type)).toBe(expected);
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe("renderSessionList", () => {
|
|
20
|
-
const list = create(SessionListSchema, {
|
|
21
|
-
totalPages: 1,
|
|
22
|
-
entries: [
|
|
23
|
-
create(SessionSchema, {
|
|
24
|
-
metadata: { id: "ses_1" },
|
|
25
|
-
spec: { agentInstanceId: "agi_1", subject: "Fix the build" },
|
|
26
|
-
}),
|
|
27
|
-
],
|
|
28
|
-
});
|
|
29
|
-
const result = { schema: SessionListSchema, message: list };
|
|
30
|
-
|
|
31
|
-
it("renders the full list envelope as protojson for json", () => {
|
|
32
|
-
const json = JSON.parse(renderSessionList(result, "json"));
|
|
33
|
-
expect(json.entries[0].metadata.id).toBe("ses_1");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("renders a table with the session columns", () => {
|
|
37
|
-
const table = renderSessionList(result, "table");
|
|
38
|
-
expect(table).toContain("SESSION ID");
|
|
39
|
-
expect(table).toContain("ses_1");
|
|
40
|
-
expect(table).toContain("agi_1");
|
|
41
|
-
expect(table).toContain("Fix the build");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("renders an empty notice for no sessions", () => {
|
|
45
|
-
const empty = { schema: SessionListSchema, message: create(SessionListSchema, {}) };
|
|
46
|
-
expect(renderSessionList(empty, "table").toLowerCase()).toContain("no sessions");
|
|
47
|
-
});
|
|
48
|
-
});
|