@stigmer/cli 3.5.3 → 3.6.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 (72) hide show
  1. package/commands/list.js +8 -5
  2. package/commands/list.js.map +1 -1
  3. package/commands/resume.js +5 -1
  4. package/commands/resume.js.map +1 -1
  5. package/commands/schedule.d.ts +3 -0
  6. package/commands/schedule.d.ts.map +1 -0
  7. package/commands/schedule.js +57 -0
  8. package/commands/schedule.js.map +1 -0
  9. package/package.json +5 -5
  10. package/program.d.ts.map +1 -1
  11. package/program.js +2 -0
  12. package/program.js.map +1 -1
  13. package/registry/metadata.d.ts.map +1 -1
  14. package/registry/metadata.js +2 -0
  15. package/registry/metadata.js.map +1 -1
  16. package/registry/verb-support.d.ts.map +1 -1
  17. package/registry/verb-support.js +29 -21
  18. package/registry/verb-support.js.map +1 -1
  19. package/registry/verbs.d.ts.map +1 -1
  20. package/registry/verbs.js +0 -1
  21. package/registry/verbs.js.map +1 -1
  22. package/resources/apply/apply.d.ts +6 -1
  23. package/resources/apply/apply.d.ts.map +1 -1
  24. package/resources/apply/apply.js +14 -24
  25. package/resources/apply/apply.js.map +1 -1
  26. package/resources/apply/handlers.d.ts +9 -1
  27. package/resources/apply/handlers.d.ts.map +1 -1
  28. package/resources/apply/handlers.js +50 -148
  29. package/resources/apply/handlers.js.map +1 -1
  30. package/resources/delete.d.ts +12 -0
  31. package/resources/delete.d.ts.map +1 -1
  32. package/resources/delete.js +8 -4
  33. package/resources/delete.js.map +1 -1
  34. package/resources/execution.d.ts +14 -4
  35. package/resources/execution.d.ts.map +1 -1
  36. package/resources/execution.js +16 -6
  37. package/resources/execution.js.map +1 -1
  38. package/resources/get-bindings.d.ts +1 -0
  39. package/resources/get-bindings.d.ts.map +1 -1
  40. package/resources/get-bindings.js +8 -1
  41. package/resources/get-bindings.js.map +1 -1
  42. package/resources/list.d.ts +4 -0
  43. package/resources/list.d.ts.map +1 -1
  44. package/resources/list.js +95 -27
  45. package/resources/list.js.map +1 -1
  46. package/resources/reference.d.ts +2 -0
  47. package/resources/reference.d.ts.map +1 -1
  48. package/resources/reference.js +4 -0
  49. package/resources/reference.js.map +1 -1
  50. package/resources/schedule.d.ts +19 -0
  51. package/resources/schedule.d.ts.map +1 -0
  52. package/resources/schedule.js +96 -0
  53. package/resources/schedule.js.map +1 -0
  54. package/src/commands/list.ts +8 -5
  55. package/src/commands/resume.ts +8 -1
  56. package/src/commands/schedule.ts +70 -0
  57. package/src/program.ts +2 -0
  58. package/src/registry/metadata.ts +2 -0
  59. package/src/registry/registry.test.ts +113 -8
  60. package/src/registry/verb-support.ts +29 -21
  61. package/src/registry/verbs.ts +0 -1
  62. package/src/resources/apply/apply.integration.test.ts +132 -0
  63. package/src/resources/apply/apply.ts +16 -25
  64. package/src/resources/apply/handlers.ts +60 -150
  65. package/src/resources/delete.ts +8 -4
  66. package/src/resources/execution.ts +20 -6
  67. package/src/resources/get-bindings.ts +8 -1
  68. package/src/resources/list.ts +107 -31
  69. package/src/resources/mutate-verbs.integration.test.ts +78 -4
  70. package/src/resources/read-verbs.integration.test.ts +124 -5
  71. package/src/resources/reference.ts +5 -0
  72. package/src/resources/schedule.ts +115 -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
@@ -0,0 +1,115 @@
1
+ // `schedule resume` dispatch: clear a platform auto-pause from a schedule
2
+ // and report what was cleared.
3
+ //
4
+ // "Paused" is the platform's latch (status.paused_reason, written after
5
+ // repeated failed runs), distinct from "disabled", the owner's switch
6
+ // (spec.enabled) — two words, two levers (see docs/vocabulary.md). The
7
+ // resume RPC is deliberately the only path that clears the latch: applying
8
+ // a manifest preserves status verbatim, so no declarative workflow can
9
+ // silently un-pause a failing schedule.
10
+ //
11
+ // The pre-state read exists for honest messaging only: the RPC is
12
+ // idempotent and its response always shows a cleared latch, so "resumed"
13
+ // versus "was not paused" can only be told apart by looking first. The
14
+ // resume call is made either way — on the no-op path it doubles as a
15
+ // harmless re-arm of the schedule's clock.
16
+
17
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
18
+ import type { Schedule } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/api_pb";
19
+ import type { Stigmer } from "@stigmer/sdk";
20
+ import { UsageError } from "../errors/index.js";
21
+ import { CommandResult } from "../output/index.js";
22
+ import { parseReference } from "./reference.js";
23
+
24
+ /** The schedule kind's canonical id prefix (proto kind_meta). */
25
+ const SCHEDULE_ID_PREFIX = "sch";
26
+
27
+ /**
28
+ * Resolve `ref` (a `sch_…` id, `org/slug`, or bare slug against `org`),
29
+ * clear its platform pause, and describe the outcome.
30
+ */
31
+ export async function resumeSchedule(stigmer: Stigmer, ref: string, org: string): Promise<CommandResult> {
32
+ const before = await loadSchedule(stigmer, ref, org);
33
+ const id = before.metadata?.id ?? "";
34
+ if (id === "") {
35
+ throw new UsageError(`Schedule '${ref}' has no id — cannot resume`);
36
+ }
37
+
38
+ const wasPausedReason = before.status?.pausedReason ?? "";
39
+ const wasFailures = before.status?.consecutiveFailures ?? 0;
40
+
41
+ const resumed = await stigmer.schedule.resume(id);
42
+
43
+ return describeOutcome(resumed, wasPausedReason, wasFailures);
44
+ }
45
+
46
+ async function loadSchedule(stigmer: Stigmer, ref: string, org: string): Promise<Schedule> {
47
+ const parsed = parseReference(ref, org, SCHEDULE_ID_PREFIX);
48
+ if (parsed.kind === "id") {
49
+ return stigmer.schedule.get(parsed.id);
50
+ }
51
+ if (parsed.org === "") {
52
+ throw new UsageError(
53
+ "organization not set\n\nSet it with:\n stigmer config context set --org <org>\n stigmer schedule resume --org <org> ...",
54
+ );
55
+ }
56
+ return stigmer.schedule.getByReference({ org: parsed.org, slug: parsed.slug });
57
+ }
58
+
59
+ /**
60
+ * Resolve `ref` (a `sch_…` id, `org/slug`, or bare slug against `org`) and
61
+ * fire it once, immediately (project DD-014).
62
+ *
63
+ * The fire is asynchronous: the RPC answers with the schedule, and the run
64
+ * lands on status (last_fire_at / last_execution_id) as it starts — the
65
+ * same observability contract cron fires have. Disabled and platform-paused
66
+ * schedules are refused by the server with teaching copy; the CLI relays
67
+ * the server's message verbatim (never pattern-match refusal text).
68
+ */
69
+ export async function triggerSchedule(stigmer: Stigmer, ref: string, org: string): Promise<CommandResult> {
70
+ const before = await loadSchedule(stigmer, ref, org);
71
+ const id = before.metadata?.id ?? "";
72
+ if (id === "") {
73
+ throw new UsageError(`Schedule '${ref}' has no id — cannot trigger`);
74
+ }
75
+
76
+ const triggered = await stigmer.schedule.trigger(id);
77
+
78
+ const slug = triggered.metadata?.slug ?? "";
79
+ const name = triggered.metadata?.name || slug;
80
+ const result = CommandResult.success(`Schedule '${name}' triggered — a run is starting`);
81
+
82
+ const section = result.addSection();
83
+ const orgSlug = triggered.metadata?.org ? `${triggered.metadata.org}/${slug}` : slug;
84
+ section.field("Watch it land", `stigmer get schedule ${orgSlug} (status.last_execution_id)`);
85
+ if (triggered.status?.nextFireAt !== undefined) {
86
+ section.field("Next cron fire", timestampDate(triggered.status.nextFireAt).toISOString());
87
+ }
88
+
89
+ return result;
90
+ }
91
+
92
+ function describeOutcome(schedule: Schedule, wasPausedReason: string, wasFailures: number): CommandResult {
93
+ const slug = schedule.metadata?.slug ?? "";
94
+ const name = schedule.metadata?.name || slug;
95
+ const wasPaused = wasPausedReason !== "" || wasFailures > 0;
96
+
97
+ const result = CommandResult.success(
98
+ wasPaused ? `Schedule '${name}' resumed` : `Schedule '${name}' was not paused — nothing to clear`,
99
+ );
100
+
101
+ if (wasPaused) {
102
+ const section = result.addSection("Cleared");
103
+ if (wasPausedReason !== "") section.field("Pause reason", wasPausedReason);
104
+ if (wasFailures > 0) section.field("Consecutive failures", String(wasFailures));
105
+ }
106
+
107
+ if (schedule.spec?.enabled !== true) {
108
+ result.hint("This schedule is disabled (enabled: false) — it will not fire until you enable it:");
109
+ result.hint(` edit the manifest to 'enabled: true' and run: stigmer apply -f <file>`);
110
+ } else if (schedule.status?.nextFireAt !== undefined) {
111
+ result.addSection().field("Next fire", timestampDate(schedule.status.nextFireAt).toISOString());
112
+ }
113
+
114
+ return result;
115
+ }