@stigmer/cli 3.5.3 → 3.7.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.
Files changed (96) hide show
  1. package/commands/agent-exec-flags.d.ts +1 -0
  2. package/commands/agent-exec-flags.d.ts.map +1 -1
  3. package/commands/agent-exec-flags.js +3 -1
  4. package/commands/agent-exec-flags.js.map +1 -1
  5. package/commands/list.js +8 -5
  6. package/commands/list.js.map +1 -1
  7. package/commands/mcp-server.js +1 -1
  8. package/commands/resume.js +5 -1
  9. package/commands/resume.js.map +1 -1
  10. package/commands/schedule.d.ts +3 -0
  11. package/commands/schedule.d.ts.map +1 -0
  12. package/commands/schedule.js +57 -0
  13. package/commands/schedule.js.map +1 -0
  14. package/package.json +5 -5
  15. package/program.d.ts.map +1 -1
  16. package/program.js +2 -0
  17. package/program.js.map +1 -1
  18. package/registry/metadata.d.ts.map +1 -1
  19. package/registry/metadata.js +2 -0
  20. package/registry/metadata.js.map +1 -1
  21. package/registry/verb-support.d.ts.map +1 -1
  22. package/registry/verb-support.js +29 -21
  23. package/registry/verb-support.js.map +1 -1
  24. package/registry/verbs.d.ts.map +1 -1
  25. package/registry/verbs.js +0 -1
  26. package/registry/verbs.js.map +1 -1
  27. package/resources/apply/apply.d.ts +6 -1
  28. package/resources/apply/apply.d.ts.map +1 -1
  29. package/resources/apply/apply.js +14 -24
  30. package/resources/apply/apply.js.map +1 -1
  31. package/resources/apply/handlers.d.ts +9 -1
  32. package/resources/apply/handlers.d.ts.map +1 -1
  33. package/resources/apply/handlers.js +50 -148
  34. package/resources/apply/handlers.js.map +1 -1
  35. package/resources/delete.d.ts +12 -0
  36. package/resources/delete.d.ts.map +1 -1
  37. package/resources/delete.js +8 -4
  38. package/resources/delete.js.map +1 -1
  39. package/resources/execution.d.ts +14 -4
  40. package/resources/execution.d.ts.map +1 -1
  41. package/resources/execution.js +16 -6
  42. package/resources/execution.js.map +1 -1
  43. package/resources/get-bindings.d.ts +1 -0
  44. package/resources/get-bindings.d.ts.map +1 -1
  45. package/resources/get-bindings.js +8 -1
  46. package/resources/get-bindings.js.map +1 -1
  47. package/resources/list.d.ts +4 -0
  48. package/resources/list.d.ts.map +1 -1
  49. package/resources/list.js +95 -27
  50. package/resources/list.js.map +1 -1
  51. package/resources/reference.d.ts +2 -0
  52. package/resources/reference.d.ts.map +1 -1
  53. package/resources/reference.js +4 -0
  54. package/resources/reference.js.map +1 -1
  55. package/resources/run/agent-exec.d.ts.map +1 -1
  56. package/resources/run/agent-exec.js +1 -0
  57. package/resources/run/agent-exec.js.map +1 -1
  58. package/resources/run/create.d.ts +2 -1
  59. package/resources/run/create.d.ts.map +1 -1
  60. package/resources/run/create.js +12 -6
  61. package/resources/run/create.js.map +1 -1
  62. package/resources/run/prepare.d.ts +15 -0
  63. package/resources/run/prepare.d.ts.map +1 -1
  64. package/resources/run/prepare.js +13 -0
  65. package/resources/run/prepare.js.map +1 -1
  66. package/resources/schedule.d.ts +22 -0
  67. package/resources/schedule.d.ts.map +1 -0
  68. package/resources/schedule.js +114 -0
  69. package/resources/schedule.js.map +1 -0
  70. package/src/commands/agent-exec-flags.ts +5 -2
  71. package/src/commands/list.ts +8 -5
  72. package/src/commands/mcp-server.ts +1 -1
  73. package/src/commands/resume.ts +8 -1
  74. package/src/commands/schedule.ts +70 -0
  75. package/src/program.ts +2 -0
  76. package/src/registry/metadata.ts +2 -0
  77. package/src/registry/registry.test.ts +113 -8
  78. package/src/registry/verb-support.ts +29 -21
  79. package/src/registry/verbs.ts +0 -1
  80. package/src/resources/apply/apply.integration.test.ts +132 -0
  81. package/src/resources/apply/apply.ts +16 -25
  82. package/src/resources/apply/handlers.ts +60 -150
  83. package/src/resources/delete.ts +8 -4
  84. package/src/resources/execution.ts +20 -6
  85. package/src/resources/get-bindings.ts +8 -1
  86. package/src/resources/list.ts +107 -31
  87. package/src/resources/mutate-verbs.integration.test.ts +78 -4
  88. package/src/resources/read-verbs.integration.test.ts +124 -5
  89. package/src/resources/reference.ts +5 -0
  90. package/src/resources/run/agent-exec.test.ts +1 -0
  91. package/src/resources/run/agent-exec.ts +1 -0
  92. package/src/resources/run/create.test.ts +44 -1
  93. package/src/resources/run/create.ts +16 -7
  94. package/src/resources/run/prepare.test.ts +14 -1
  95. package/src/resources/run/prepare.ts +25 -0
  96. package/src/resources/schedule.ts +132 -0
@@ -34,9 +34,15 @@ import { EnvironmentQueryController } from "@stigmer/protos/ai/stigmer/agentic/e
34
34
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
35
35
  import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
36
36
  import { McpServerQueryController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/query_pb";
37
+ import { ScheduleSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
38
+ import { ScheduleCommandController } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/command_pb";
39
+ import { ScheduleQueryController } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/query_pb";
37
40
  import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
38
41
  import { WorkflowCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/command_pb";
39
42
  import { WorkflowQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/query_pb";
43
+ import { WorkflowInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/api_pb";
44
+ import { WorkflowInstanceCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/command_pb";
45
+ import { WorkflowInstanceQueryController } from "@stigmer/protos/ai/stigmer/agentic/workflowinstance/v1/query_pb";
40
46
  import { createNodeClient, normalizeEndpoint } from "@stigmer/sdk/node";
41
47
  import type { Stigmer } from "@stigmer/sdk";
42
48
  import { createServer as createHttp2Server, type Http2Server, type ServerHttp2Session } from "node:http2";
@@ -85,6 +91,14 @@ const knownChannelApp = create(ChannelAppSchema, {
85
91
  metadata: { name: "clinic-meta-app", slug: "clinic-meta-app", org: "acme", id: "chapp_1" },
86
92
  });
87
93
 
94
+ const knownSchedule = create(ScheduleSchema, {
95
+ metadata: { name: "daily-fee-reminders", slug: "daily-fee-reminders", org: "acme", id: "sch_1" },
96
+ });
97
+
98
+ const knownWorkflowInstance = create(WorkflowInstanceSchema, {
99
+ metadata: { name: "deploy-default", slug: "deploy-default", org: "acme", id: "win_1" },
100
+ });
101
+
88
102
  // Pending execution (cancellable) vs. an already-terminal one.
89
103
  const pendingExecution = create(AgentExecutionSchema, {
90
104
  metadata: { id: "aex_run" },
@@ -110,8 +124,11 @@ let tagCalls: { workflowId: string; versionHash: string; tag: string }[] = [];
110
124
  let datastoreDeletes: { resourceId: string; force: boolean }[] = [];
111
125
  let environmentDeletes: { resourceId: string; force: boolean }[] = [];
112
126
  let channelAppDeletes: { resourceId: string; force: boolean }[] = [];
113
- // agent_channel's delete is a typed ID — no force field exists on the wire.
127
+ // agent_channel, schedule, and workflow_instance delete by typed ID — no
128
+ // force field exists on their wire contracts.
114
129
  let channelDeleteIds: string[] = [];
130
+ let scheduleDeleteIds: string[] = [];
131
+ let workflowInstanceDeleteIds: string[] = [];
115
132
 
116
133
  beforeEach(() => {
117
134
  cancelCalls = [];
@@ -120,6 +137,8 @@ beforeEach(() => {
120
137
  environmentDeletes = [];
121
138
  channelAppDeletes = [];
122
139
  channelDeleteIds = [];
140
+ scheduleDeleteIds = [];
141
+ workflowInstanceDeleteIds = [];
123
142
  });
124
143
 
125
144
  beforeAll(async () => {
@@ -216,6 +235,32 @@ beforeAll(async () => {
216
235
  },
217
236
  });
218
237
 
238
+ router.service(ScheduleQueryController, {
239
+ getByReference: (req) => {
240
+ if (req.slug !== "daily-fee-reminders") throw new ConnectError("schedule not found", Code.NotFound);
241
+ return knownSchedule;
242
+ },
243
+ });
244
+ router.service(ScheduleCommandController, {
245
+ delete: (req) => {
246
+ scheduleDeleteIds.push(req.value);
247
+ return knownSchedule;
248
+ },
249
+ });
250
+
251
+ router.service(WorkflowInstanceQueryController, {
252
+ getByReference: (req) => {
253
+ if (req.slug !== "deploy-default") throw new ConnectError("workflow instance not found", Code.NotFound);
254
+ return knownWorkflowInstance;
255
+ },
256
+ });
257
+ router.service(WorkflowInstanceCommandController, {
258
+ delete: (req) => {
259
+ workflowInstanceDeleteIds.push(req.value);
260
+ return knownWorkflowInstance;
261
+ },
262
+ });
263
+
219
264
  router.service(WorkflowQueryController, {
220
265
  getByReference: (req) => {
221
266
  if (req.slug !== "deploy") throw new ConnectError("workflow not found", Code.NotFound);
@@ -306,22 +351,51 @@ describe("delete (standard kinds)", () => {
306
351
  expect(classify(err)?.exitCode).toBe(ExitCode.NotFound);
307
352
  });
308
353
 
354
+ it("deletes a schedule via its typed ID (tears down the Temporal artifact server-side)", async () => {
355
+ const plan = await planDelete(client, "schedule", "daily-fee-reminders", "acme");
356
+ const result = await plan.perform();
357
+ expect(result.status).toBe("success");
358
+ expect(result.message).toBe("Schedule deleted successfully");
359
+ expect(scheduleDeleteIds).toEqual(["sch_1"]);
360
+ });
361
+
362
+ it("deletes a workflow instance via its typed ID", async () => {
363
+ const plan = await planDelete(client, "workflow-instance", "deploy-default", "acme");
364
+ const result = await plan.perform();
365
+ expect(result.status).toBe("success");
366
+ expect(result.message).toBe("Workflow Instance deleted successfully");
367
+ expect(workflowInstanceDeleteIds).toEqual(["win_1"]);
368
+ });
369
+
309
370
  it("rejects an unknown type and lists exactly the wired types", async () => {
310
371
  const err = await planDelete(client, "bogus", "x", "acme").catch((e) => e);
311
372
  expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
312
373
  // The list is derived from DELETE_HANDLERS (+ the two special cases), so
313
- // it must name the wired kinds and never the declared-but-unwired ones.
374
+ // it must name the wired kinds and never the narrowed ones (#354).
314
375
  const message = String(err.message);
315
- for (const wired of ["datastore", "environment", "agentchannel", "channelapp", "execution", "organization"]) {
376
+ for (const wired of [
377
+ "datastore",
378
+ "environment",
379
+ "agentchannel",
380
+ "channelapp",
381
+ "schedule",
382
+ "workflowinstance",
383
+ "execution",
384
+ "organization",
385
+ ]) {
316
386
  expect(message).toContain(wired);
317
387
  }
318
388
  expect(message).not.toContain("session");
319
389
  expect(message).not.toContain("oauthapp");
320
390
  });
321
391
 
322
- it("rejects a kind that has no delete handler with a usage error", async () => {
392
+ it("rejects a narrowed kind at the verb gate with a usage error", async () => {
393
+ // session's delete verb is narrowed out of the matrix
394
+ // (stigmer/stigmer#354), so the refusal now happens at the verb-support
395
+ // gate ("does not support") rather than the dispatch fall-through.
323
396
  const err = await planDelete(client, "session", "ses_1", "acme").catch((e) => e);
324
397
  expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
398
+ expect(String(err.message)).toContain("does not support");
325
399
  });
326
400
  });
327
401
 
@@ -21,6 +21,8 @@ import { ChannelAppSchema } from "@stigmer/protos/ai/stigmer/agentic/channelapp/
21
21
  import { ChannelAppQueryController } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/query_pb";
22
22
  import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
23
23
  import { EnvironmentQueryController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/query_pb";
24
+ import { ScheduleSchema } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
25
+ import { ScheduleQueryController } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/query_pb";
24
26
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
25
27
  import { ApiKeySchema } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
26
28
  import { ApiKeyQueryController } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/query_pb";
@@ -142,6 +144,44 @@ const secondChannelApp = create(ChannelAppSchema, {
142
144
  },
143
145
  });
144
146
 
147
+ // Spec/status populated to exercise every SCHEDULE_TABLE column — and the
148
+ // exact condition the table exists to surface (stigmer/stigmer#352): the
149
+ // owner's switch is ON (spec.enabled) while the platform's failure latch is
150
+ // SET (status.paused_reason), two different states with two different
151
+ // remedies.
152
+ const pausedSchedule = create(ScheduleSchema, {
153
+ apiVersion: "agentic.stigmer.ai/v1",
154
+ kind: "Schedule",
155
+ metadata: { name: "daily-fee-reminders", slug: "daily-fee-reminders", org: "acme", id: "sch_1" },
156
+ spec: {
157
+ cron: "0 9 * * *",
158
+ timeZone: "Asia/Kolkata",
159
+ enabled: true,
160
+ target: {
161
+ case: "agent",
162
+ value: { agentRef: { kind: ApiResourceKind.agent, org: "acme", slug: "clinic-assistant" } },
163
+ },
164
+ },
165
+ status: { pausedReason: "5 consecutive failed runs", consecutiveFailures: 5 },
166
+ });
167
+
168
+ // Owner-disabled and never fired: empty status, so the row must derive
169
+ // "active" from an absent paused_reason and print no bogus timestamps.
170
+ const disabledSchedule = create(ScheduleSchema, {
171
+ apiVersion: "agentic.stigmer.ai/v1",
172
+ kind: "Schedule",
173
+ metadata: { name: "weekly-digest", slug: "weekly-digest", org: "acme", id: "sch_2" },
174
+ spec: {
175
+ cron: "30 6 * * 1",
176
+ timeZone: "UTC",
177
+ enabled: false,
178
+ target: {
179
+ case: "agent",
180
+ value: { agentRef: { kind: ApiResourceKind.agent, org: "acme", slug: "reviewer" } },
181
+ },
182
+ },
183
+ });
184
+
145
185
  let backend: Http2Server;
146
186
  let client: Stigmer;
147
187
  const openSessions = new Set<ServerHttp2Session>();
@@ -226,6 +266,20 @@ beforeAll(async () => {
226
266
  return { entries: [knownChannelApp, secondChannelApp] };
227
267
  },
228
268
  });
269
+ router.service(ScheduleQueryController, {
270
+ get: (req) => {
271
+ if (req.value !== "sch_1") throw new ConnectError("schedule not found", Code.NotFound);
272
+ return pausedSchedule;
273
+ },
274
+ getByReference: (req) => {
275
+ if (req.slug !== "daily-fee-reminders") throw new ConnectError("schedule not found", Code.NotFound);
276
+ return pausedSchedule;
277
+ },
278
+ list: (req) => {
279
+ if (req.org !== "acme") throw new ConnectError("org is required", Code.InvalidArgument);
280
+ return { totalCount: 2, items: [pausedSchedule, disabledSchedule] };
281
+ },
282
+ });
229
283
  };
230
284
  backend = createHttp2Server(connectNodeAdapter({ routes }));
231
285
  backend.on("session", (session) => {
@@ -341,6 +395,27 @@ describe("get integration", () => {
341
395
  });
342
396
  });
343
397
 
398
+ it("fetches a schedule by org/slug and renders backend protojson", async () => {
399
+ const { schema, message } = await fetchResource(client, ApiResourceKind.schedule, {
400
+ kind: "ref",
401
+ org: "acme",
402
+ slug: "daily-fee-reminders",
403
+ });
404
+ const rendered = JSON.parse(renderResource(schema, message, "json"));
405
+ expect(rendered).toEqual(toJson(ScheduleSchema, pausedSchedule, { useProtoFieldName: true }));
406
+ });
407
+
408
+ it("fetches a schedule by ID, exposing the pause diagnosis get exists for", async () => {
409
+ const { message } = await fetchResource(client, ApiResourceKind.schedule, { kind: "id", id: "sch_1" });
410
+ // The full resource is where an operator reads WHY the platform paused
411
+ // a schedule (paused_reason + the streak) — the list table only flags it.
412
+ expect(JSON.parse(renderResource(ScheduleSchema, message, "json"))).toMatchObject({
413
+ metadata: { id: "sch_1" },
414
+ spec: { enabled: true },
415
+ status: { paused_reason: "5 consecutive failed runs", consecutive_failures: 5 },
416
+ });
417
+ });
418
+
344
419
  it("maps a NotFound backend error to ExitCode.NotFound", async () => {
345
420
  const err = await fetchResource(client, ApiResourceKind.agent, { kind: "id", id: "missing" }).catch((e) => e);
346
421
  const classified = classify(err);
@@ -348,8 +423,11 @@ describe("get integration", () => {
348
423
  });
349
424
 
350
425
  it("rejects an unsupported kind with a usage error", async () => {
351
- // session declares Get in the registry but has no get binding — the
352
- // canonical still-unwired kind (environment gained a binding).
426
+ // session's read verbs are narrowed out of the verb matrix
427
+ // (stigmer/stigmer#354): the command layer refuses at the gate, and this
428
+ // resource-layer fallback answers anyone bypassing the gate. Wiring
429
+ // session get requires BOTH the binding and the matrix line — the
430
+ // registry conformance test enforces the pairing.
353
431
  const err = await fetchResource(client, ApiResourceKind.session, { kind: "id", id: "x" }).catch((e) => e);
354
432
  expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
355
433
  });
@@ -441,10 +519,51 @@ describe("list integration", () => {
441
519
  expect(JSON.parse(out)).toEqual([toJson(ChannelAppSchema, knownChannelApp, { useProtoFieldName: true })]);
442
520
  });
443
521
 
522
+ it("lists schedules as a table keeping the owner switch and the platform latch distinct", async () => {
523
+ const out = await listResources(client, ApiResourceKind.schedule, "acme", 50, "table");
524
+ for (const header of ["ID", "SLUG", "TARGET", "CRON", "TZ", "ENABLED", "STATE"]) {
525
+ expect(out).toContain(header);
526
+ }
527
+ const rows = out.split("\n");
528
+ // The stigmer/stigmer#352 condition: enabled by the owner, paused by the
529
+ // platform. Collapsing these into one column would hide exactly this.
530
+ const pausedRow = rows.find((line) => line.includes("sch_1"));
531
+ expect(pausedRow).toContain("acme/clinic-assistant");
532
+ expect(pausedRow).toContain("0 9 * * *");
533
+ expect(pausedRow).toContain("Asia/Kolkata");
534
+ expect(pausedRow).toContain("true");
535
+ expect(pausedRow).toContain("paused");
536
+ // Owner-disabled with an empty status: derived state is "active" (no
537
+ // platform latch), and the absent timestamps render nothing bogus.
538
+ const disabledRow = rows.find((line) => line.includes("sch_2"));
539
+ expect(disabledRow).toContain("false");
540
+ expect(disabledRow).toContain("active");
541
+ expect(disabledRow).not.toContain("paused");
542
+ });
543
+
544
+ it("lists schedules as JSON matching backend protojson", async () => {
545
+ const out = await listResources(client, ApiResourceKind.schedule, "acme", 50, "json");
546
+ expect(JSON.parse(out)).toEqual([
547
+ toJson(ScheduleSchema, pausedSchedule, { useProtoFieldName: true }),
548
+ toJson(ScheduleSchema, disabledSchedule, { useProtoFieldName: true }),
549
+ ]);
550
+ });
551
+
552
+ it("refuses to list schedules without an org context, before any RPC", async () => {
553
+ // ListSchedulesRequest requires org (min_len 1); an unset cloud context
554
+ // resolves to "". The branch refuses with actionable copy instead of
555
+ // relaying the server's raw validation error.
556
+ const err = await listResources(client, ApiResourceKind.schedule, "", 50, "table").catch((e) => e);
557
+ expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
558
+ expect(String(err.message)).toContain("--org");
559
+ });
560
+
444
561
  it("rejects a kind whose list is unwired with a usage error", async () => {
445
- // agent_share declares List in the registry but has no list branch —
446
- // the canonical still-unwired list kind (the three cutover kinds and
447
- // environment gained branches).
562
+ // agent_share's read verbs are narrowed out of the verb matrix
563
+ // (stigmer/stigmer#354): the command layer refuses at the gate, and this
564
+ // resource-layer fallback answers anyone bypassing the gate. Wiring
565
+ // agent_share list requires BOTH the LIST_HANDLERS entry and the matrix
566
+ // line — the registry conformance test enforces the pairing.
448
567
  const err = await listResources(client, ApiResourceKind.agent_share, "acme", 50, "json").catch((e) => e);
449
568
  expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
450
569
  });
@@ -86,6 +86,11 @@ export function isSessionId(ref: string): boolean {
86
86
  return hasKindPrefix(ref, idPrefixFor(ApiResourceKind.session));
87
87
  }
88
88
 
89
+ /** True if `ref` carries the schedule ID prefix (`sch_…`/`sch-…`). */
90
+ export function isScheduleId(ref: string): boolean {
91
+ return hasKindPrefix(ref, idPrefixFor(ApiResourceKind.schedule));
92
+ }
93
+
89
94
  /**
90
95
  * True if `ref` starts with ANY known resource-ID prefix (length-agnostic).
91
96
  * Mirrors Go's reference.HasResourceIDPrefix: use it to detect *intent* (the
@@ -46,6 +46,7 @@ function makePrepared(overrides: Partial<PreparedRun> = {}): PreparedRun {
46
46
  model: "",
47
47
  autoApproveAll: false,
48
48
  mode: "",
49
+ serviceTier: "",
49
50
  ...overrides,
50
51
  };
51
52
  }
@@ -42,6 +42,7 @@ export async function executeResolvedAgent(input: ResolvedAgentExecInput): Promi
42
42
  workspaceEntries: prepared.workspaceEntries,
43
43
  model: prepared.model,
44
44
  mode: prepared.mode,
45
+ serviceTier: prepared.serviceTier,
45
46
  autoApproveAll: prepared.autoApproveAll,
46
47
  });
47
48
 
@@ -4,7 +4,7 @@
4
4
  // is faked to capture the exact proto sent to the RPC.
5
5
 
6
6
  import { describe, expect, it } from "vitest";
7
- import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
7
+ import { InteractionMode, ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
8
8
  import { create } from "@bufbuild/protobuf";
9
9
  import {
10
10
  LocalPathSourceSchema,
@@ -40,6 +40,7 @@ describe("createAgentExecution", () => {
40
40
  workspaceEntries: [],
41
41
  model: "claude",
42
42
  mode: "plan",
43
+ serviceTier: "",
43
44
  autoApproveAll: true,
44
45
  });
45
46
 
@@ -67,12 +68,51 @@ describe("createAgentExecution", () => {
67
68
  workspaceEntries: [],
68
69
  model: "",
69
70
  mode: "",
71
+ serviceTier: "",
70
72
  autoApproveAll: false,
71
73
  });
72
74
  expect(exec.spec?.message).toBe("hi");
73
75
  expect(exec.spec?.executionConfig).toBeUndefined();
74
76
  });
75
77
 
78
+ it("maps --service-tier fast to the enum (#357)", async () => {
79
+ const { fn } = fakeController();
80
+ const exec = await createAgentExecution(fn, {
81
+ agentId: "agt_1",
82
+ orgId: "acme",
83
+ message: "x",
84
+ runtimeEnv: {},
85
+ attachments: [],
86
+ workspaceFileRefs: [],
87
+ workspaceEntries: [],
88
+ model: "composer-2.5",
89
+ mode: "",
90
+ serviceTier: "fast",
91
+ autoApproveAll: false,
92
+ });
93
+ expect(exec.spec?.executionConfig?.serviceTier).toBe(ServiceTier.FAST);
94
+ });
95
+
96
+ it("maps an explicit --service-tier standard to STANDARD, not UNSPECIFIED", async () => {
97
+ // Unspecified-vs-explicit-standard is a load-bearing ledger
98
+ // distinction (#357): an explicit choice must survive to the proto.
99
+ const { fn } = fakeController();
100
+ const exec = await createAgentExecution(fn, {
101
+ agentId: "agt_1",
102
+ orgId: "acme",
103
+ message: "x",
104
+ runtimeEnv: {},
105
+ attachments: [],
106
+ workspaceFileRefs: [],
107
+ workspaceEntries: [],
108
+ model: "",
109
+ mode: "",
110
+ serviceTier: "standard",
111
+ autoApproveAll: false,
112
+ });
113
+ expect(exec.spec?.executionConfig?.serviceTier).toBe(ServiceTier.STANDARD);
114
+ });
115
+
76
116
  it("leaves InteractionMode unspecified for agent mode", async () => {
77
117
  const { fn } = fakeController();
78
118
  const exec = await createAgentExecution(fn, {
@@ -85,6 +125,7 @@ describe("createAgentExecution", () => {
85
125
  workspaceEntries: [],
86
126
  model: "m",
87
127
  mode: "agent",
128
+ serviceTier: "",
88
129
  autoApproveAll: false,
89
130
  });
90
131
  expect(exec.spec?.sessionId).toBe("ses_1");
@@ -110,6 +151,7 @@ describe("createAgentExecution", () => {
110
151
  workspaceEntries: [entry],
111
152
  model: "",
112
153
  mode: "",
154
+ serviceTier: "",
113
155
  autoApproveAll: false,
114
156
  });
115
157
 
@@ -132,6 +174,7 @@ describe("createAgentExecution", () => {
132
174
  workspaceEntries: [],
133
175
  model: "",
134
176
  mode: "",
177
+ serviceTier: "",
135
178
  autoApproveAll: false,
136
179
  });
137
180
  expect(exec.spec?.sessionSpec).toBeUndefined();
@@ -10,7 +10,7 @@ import type { Client } from "@connectrpc/connect";
10
10
  import { create, type DescService } from "@bufbuild/protobuf";
11
11
  import { type AgentExecution, AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
12
12
  import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
13
- import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
13
+ import { InteractionMode, ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
14
14
  import type { Attachment } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
15
15
  import {
16
16
  AgentExecutionSpecSchema,
@@ -29,7 +29,7 @@ import { WorkflowExecutionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/
29
29
  import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
30
30
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
31
31
  import type { RuntimeEnv } from "./env.js";
32
- import type { RunMode } from "./prepare.js";
32
+ import type { RunMode, ServiceTierFlag } from "./prepare.js";
33
33
 
34
34
  const API_VERSION = "agentic.stigmer.ai/v1";
35
35
 
@@ -57,6 +57,7 @@ export interface CreateAgentExecutionInput {
57
57
  readonly workspaceEntries: readonly WorkspaceEntry[];
58
58
  readonly model: string;
59
59
  readonly mode: RunMode;
60
+ readonly serviceTier: ServiceTierFlag;
60
61
  readonly autoApproveAll: boolean;
61
62
  }
62
63
 
@@ -83,7 +84,7 @@ export async function createAgentExecution(
83
84
  input.workspaceEntries.length > 0
84
85
  ? create(SessionSpecSchema, { workspaceEntries: [...input.workspaceEntries] })
85
86
  : undefined,
86
- executionConfig: buildExecutionConfig(input.model, input.mode),
87
+ executionConfig: buildExecutionConfig(input.model, input.mode, input.serviceTier),
87
88
  }),
88
89
  });
89
90
  return controller(AgentExecutionCommandController).create(execution);
@@ -119,14 +120,22 @@ export async function createWorkflowExecution(
119
120
  return controller(WorkflowExecutionCommandController).create(execution);
120
121
  }
121
122
 
122
- // Build ExecutionConfig, or undefined when neither flag is set so the backend
123
+ // Build ExecutionConfig, or undefined when no flag is set so the backend
123
124
  // applies its defaults. Mirrors Go's buildExecutionConfig (only "plan" maps to a
124
- // non-default InteractionMode; "agent"/"" leave it unspecified).
125
- function buildExecutionConfig(model: string, mode: RunMode): ExecutionConfig | undefined {
126
- if (model === "" && mode === "") return undefined;
125
+ // non-default InteractionMode; "agent"/"" leave it unspecified). An explicit
126
+ // --service-tier value maps to the enum even for "standard": unspecified vs
127
+ // explicit-standard is a load-bearing ledger distinction (#357).
128
+ function buildExecutionConfig(
129
+ model: string,
130
+ mode: RunMode,
131
+ serviceTier: ServiceTierFlag,
132
+ ): ExecutionConfig | undefined {
133
+ if (model === "" && mode === "" && serviceTier === "") return undefined;
127
134
  const cfg = create(ExecutionConfigSchema);
128
135
  if (model !== "") cfg.modelName = model;
129
136
  if (mode === "plan") cfg.interactionMode = InteractionMode.PLAN;
137
+ if (serviceTier === "fast") cfg.serviceTier = ServiceTier.FAST;
138
+ else if (serviceTier === "standard") cfg.serviceTier = ServiceTier.STANDARD;
130
139
  return cfg;
131
140
  }
132
141
 
@@ -5,7 +5,7 @@
5
5
  import { describe, expect, it } from "vitest";
6
6
  import { ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
7
7
  import type { Stigmer } from "@stigmer/sdk";
8
- import { type AgentExecFlags, parseApprovalAction, prepareAgentExec, validateMode } from "./prepare.js";
8
+ import { type AgentExecFlags, parseApprovalAction, prepareAgentExec, validateMode, validateServiceTier } from "./prepare.js";
9
9
 
10
10
  describe("parseApprovalAction", () => {
11
11
  it("maps each accepted value (case-insensitive)", () => {
@@ -35,6 +35,18 @@ describe("validateMode", () => {
35
35
  });
36
36
  });
37
37
 
38
+ describe("validateServiceTier", () => {
39
+ it("accepts empty, standard, and fast", () => {
40
+ expect(() => validateServiceTier("")).not.toThrow();
41
+ expect(() => validateServiceTier("standard")).not.toThrow();
42
+ expect(() => validateServiceTier("fast")).not.toThrow();
43
+ });
44
+
45
+ it("rejects anything else before a network round trip", () => {
46
+ expect(() => validateServiceTier("turbo")).toThrow(/must be "standard" or "fast"/);
47
+ });
48
+ });
49
+
38
50
  const BASE_FLAGS: AgentExecFlags = {
39
51
  message: "hi",
40
52
  attach: [],
@@ -51,6 +63,7 @@ const BASE_FLAGS: AgentExecFlags = {
51
63
  model: "",
52
64
  autoApprove: false,
53
65
  mode: "",
66
+ serviceTier: "",
54
67
  };
55
68
 
56
69
  // prepareAgentExec only touches client.agentExecution for attachment uploads;
@@ -19,6 +19,13 @@ import { localWorkspaceRoots, parseWorkspaceEntries } from "./workspace.js";
19
19
  /** The interaction mode requested for the run; "" means the agent default. */
20
20
  export type RunMode = "" | "agent" | "plan";
21
21
 
22
+ /**
23
+ * The `--service-tier` flag's value space (#357). Empty means unset —
24
+ * distinct from an explicit "standard" so the ledger keeps the
25
+ * unspecified-vs-explicit distinction.
26
+ */
27
+ export type ServiceTierFlag = "" | "standard" | "fast";
28
+
22
29
  /** Raw agent-execution flags shared by `run` and `draft` (Go's agentExecFlags). */
23
30
  export interface AgentExecFlags {
24
31
  readonly message: string;
@@ -36,6 +43,7 @@ export interface AgentExecFlags {
36
43
  readonly model: string;
37
44
  readonly autoApprove: boolean;
38
45
  readonly mode: RunMode;
46
+ readonly serviceTier: ServiceTierFlag;
39
47
  }
40
48
 
41
49
  /**
@@ -54,6 +62,7 @@ export interface PreparedRun {
54
62
  readonly model: string;
55
63
  readonly autoApproveAll: boolean;
56
64
  readonly mode: RunMode;
65
+ readonly serviceTier: ServiceTierFlag;
57
66
  }
58
67
 
59
68
  /**
@@ -68,6 +77,7 @@ export async function prepareAgentExec(
68
77
  ): Promise<PreparedRun> {
69
78
  const defaultAction = parseApprovalAction(flags.approveDefault);
70
79
  validateMode(flags.mode);
80
+ validateServiceTier(flags.serviceTier);
71
81
 
72
82
  const workspaceEntries = parseWorkspaceEntries(flags.workspace, flags.branch, flags.commit);
73
83
 
@@ -100,6 +110,7 @@ export async function prepareAgentExec(
100
110
  model: flags.model,
101
111
  autoApproveAll: flags.autoApprove,
102
112
  mode: flags.mode,
113
+ serviceTier: flags.serviceTier,
103
114
  };
104
115
  }
105
116
 
@@ -137,3 +148,17 @@ export function validateMode(mode: string): asserts mode is RunMode {
137
148
  throw new UsageError(`invalid --mode value "${mode}": must be "agent" or "plan"`);
138
149
  }
139
150
  }
151
+
152
+ /**
153
+ * Validate the `--service-tier` flag. Empty means "platform default" (which
154
+ * resolves to standard — never the provider account default, #357). The
155
+ * server refuses fast on models without a registry fast variant; this check
156
+ * only catches spelling errors before a network round trip.
157
+ */
158
+ export function validateServiceTier(tier: string): asserts tier is ServiceTierFlag {
159
+ if (tier !== "" && tier !== "standard" && tier !== "fast") {
160
+ throw new UsageError(
161
+ `invalid --service-tier value "${tier}": must be "standard" or "fast"`,
162
+ );
163
+ }
164
+ }