@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.
Files changed (82) hide show
  1. package/commands/draft.js +4 -2
  2. package/commands/draft.js.map +1 -1
  3. package/commands/list.js +7 -15
  4. package/commands/list.js.map +1 -1
  5. package/commands/run.js +4 -2
  6. package/commands/run.js.map +1 -1
  7. package/commands/seedpack.d.ts.map +1 -1
  8. package/commands/seedpack.js +27 -5
  9. package/commands/seedpack.js.map +1 -1
  10. package/local/seedpack/apply.d.ts +32 -3
  11. package/local/seedpack/apply.d.ts.map +1 -1
  12. package/local/seedpack/apply.js +59 -8
  13. package/local/seedpack/apply.js.map +1 -1
  14. package/package.json +5 -5
  15. package/registry/verb-support.d.ts.map +1 -1
  16. package/registry/verb-support.js +6 -3
  17. package/registry/verb-support.js.map +1 -1
  18. package/resources/apply/apply.d.ts.map +1 -1
  19. package/resources/apply/apply.js +10 -1
  20. package/resources/apply/apply.js.map +1 -1
  21. package/resources/connect/connect.d.ts +7 -0
  22. package/resources/connect/connect.d.ts.map +1 -1
  23. package/resources/connect/connect.js +47 -30
  24. package/resources/connect/connect.js.map +1 -1
  25. package/resources/connect/display.d.ts.map +1 -1
  26. package/resources/connect/display.js +3 -0
  27. package/resources/connect/display.js.map +1 -1
  28. package/resources/list.d.ts.map +1 -1
  29. package/resources/list.js +39 -9
  30. package/resources/list.js.map +1 -1
  31. package/resources/run/prepare.d.ts +12 -1
  32. package/resources/run/prepare.d.ts.map +1 -1
  33. package/resources/run/prepare.js +27 -2
  34. package/resources/run/prepare.js.map +1 -1
  35. package/resources/session.d.ts +0 -8
  36. package/resources/session.d.ts.map +1 -1
  37. package/resources/session.js +6 -33
  38. package/resources/session.js.map +1 -1
  39. package/resources/skill.d.ts +0 -10
  40. package/resources/skill.d.ts.map +1 -1
  41. package/resources/skill.js +8 -59
  42. package/resources/skill.js.map +1 -1
  43. package/resources/stream/events.d.ts +1 -1
  44. package/resources/stream/events.d.ts.map +1 -1
  45. package/resources/stream/events.js +8 -6
  46. package/resources/stream/events.js.map +1 -1
  47. package/resources/stream/render-plaintext.d.ts.map +1 -1
  48. package/resources/stream/render-plaintext.js +19 -5
  49. package/resources/stream/render-plaintext.js.map +1 -1
  50. package/resources/task-configs.d.ts +15 -0
  51. package/resources/task-configs.d.ts.map +1 -0
  52. package/resources/task-configs.js +135 -0
  53. package/resources/task-configs.js.map +1 -0
  54. package/resources/validate.d.ts.map +1 -1
  55. package/resources/validate.js +8 -1
  56. package/resources/validate.js.map +1 -1
  57. package/src/commands/draft.ts +4 -2
  58. package/src/commands/list.ts +7 -17
  59. package/src/commands/run.ts +4 -2
  60. package/src/commands/seedpack.ts +27 -5
  61. package/src/local/seedpack/apply.test.ts +127 -6
  62. package/src/local/seedpack/apply.ts +69 -9
  63. package/src/registry/registry.test.ts +50 -8
  64. package/src/registry/verb-support.ts +6 -3
  65. package/src/resources/apply/apply.ts +11 -1
  66. package/src/resources/connect/connect.ts +70 -44
  67. package/src/resources/connect/display.ts +3 -0
  68. package/src/resources/list.ts +40 -9
  69. package/src/resources/read-completion.integration.test.ts +14 -4
  70. package/src/resources/run/prepare.test.ts +80 -0
  71. package/src/resources/run/prepare.ts +41 -1
  72. package/src/resources/session.ts +6 -39
  73. package/src/resources/skill.test.ts +5 -77
  74. package/src/resources/skill.ts +8 -67
  75. package/src/resources/stream/events.ts +9 -7
  76. package/src/resources/stream/render-plaintext.test.ts +51 -0
  77. package/src/resources/stream/render-plaintext.ts +24 -6
  78. package/src/resources/task-configs.test.ts +98 -0
  79. package/src/resources/task-configs.ts +150 -0
  80. package/src/resources/validate.test.ts +26 -0
  81. package/src/resources/validate.ts +10 -2
  82. package/src/resources/session.test.ts +0 -48
@@ -3,7 +3,10 @@
3
3
  // The seedpack (system agents, skills, MCP servers, workflows under the
4
4
  // "stigmer" org) is applied automatically on `stigmer up` in local mode; these
5
5
  // commands bootstrap it explicitly against whatever backend is configured
6
- // (local or cloud). Apply is idempotent via a content-hash marker.
6
+ // (local or cloud). Apply is content-hash idempotent: in cloud mode the truth
7
+ // is the stigmer.ai/seedpack-hash label on the server's seedpack Project (any
8
+ // machine sees the same applied state — cloud#429); in local mode it is a
9
+ // marker file in the data dir, which lives and dies with the local backend.
7
10
  //
8
11
  // Heavy modules load lazily inside the action so `--help` stays fast (DD-001).
9
12
 
@@ -50,7 +53,11 @@ async function runApply(flags: ApplyFlags): Promise<void> {
50
53
  info: (line) => process.stderr.write(`${line}\n`),
51
54
  warn: (line) => process.stderr.write(`${line}\n`),
52
55
  },
53
- { markerDir: markerDir(), force: flags.force === true },
56
+ {
57
+ markerDir: markerDir(),
58
+ force: flags.force === true,
59
+ useServerHash: isCloudMode(load()),
60
+ },
54
61
  );
55
62
 
56
63
  const label = backendLabel();
@@ -61,11 +68,26 @@ async function runApply(flags: ApplyFlags): Promise<void> {
61
68
  }
62
69
 
63
70
  async function runStatus(flags: OutputFlags): Promise<void> {
64
- const { seedpackContentHash } = await import("../local/seedpack/apply.js");
71
+ const { readServerSeedpackHash, resolveSeedpackOrg, seedpackContentHash } = await import("../local/seedpack/apply.js");
65
72
  const { readMarker } = await import("../local/seedpack/content.js");
66
73
 
67
74
  const hash = seedpackContentHash();
68
- const stored = readMarker(markerDir());
75
+
76
+ // Cloud mode asks the server (the label on the seedpack Project) so status
77
+ // is truthful from ANY machine, not just the one that last ran apply.
78
+ let stored: string | null;
79
+ let storedLabel: string;
80
+ if (isCloudMode(load())) {
81
+ const { connectBackend } = await import("../backend.js");
82
+ const { ensureAuthenticated } = await import("../config/index.js");
83
+ const client = connectBackend();
84
+ ensureAuthenticated(client.config);
85
+ stored = await readServerSeedpackHash(client.stigmer, resolveSeedpackOrg());
86
+ storedLabel = "Applied Hash (server)";
87
+ } else {
88
+ stored = readMarker(markerDir());
89
+ storedLabel = "Applied Hash";
90
+ }
69
91
 
70
92
  const result = CommandResult.success("Seedpack status");
71
93
  const section = result.addSection("");
@@ -74,7 +96,7 @@ async function runStatus(flags: OutputFlags): Promise<void> {
74
96
  if (stored === null) {
75
97
  section.field("Status", "Not applied (run 'stigmer seedpack apply')");
76
98
  } else {
77
- section.field("Applied Hash", stored);
99
+ section.field(storedLabel, stored);
78
100
  section.field("Status", stored === hash ? "Up to date" : "Outdated (run 'stigmer seedpack apply' to update)");
79
101
  }
80
102
  renderResult(result, resultFormat(flags));
@@ -1,11 +1,19 @@
1
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { Message } from "@bufbuild/protobuf";
5
+ import type { Stigmer } from "@stigmer/sdk";
5
6
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
7
  import { buildDeclarativeItems, scanResourceFiles } from "../../resources/apply/declarative.js";
7
8
  import type { ControllerFn } from "../../resources/apply/handlers.js";
8
- import { applySeedpack, resolveSeedpackOrg, seedpackContentHash } from "./apply.js";
9
+ import {
10
+ applySeedpack,
11
+ readServerSeedpackHash,
12
+ resolveSeedpackOrg,
13
+ SEEDPACK_HASH_LABEL,
14
+ SEEDPACK_PROJECT_SLUG,
15
+ seedpackContentHash,
16
+ } from "./apply.js";
9
17
  import type { SeedpackContent } from "./content.js";
10
18
  import { extractSeedpack, readMarker, resolveSeedpackContent } from "./content.js";
11
19
 
@@ -43,27 +51,43 @@ function makeContent(): SeedpackContent {
43
51
  return { dir, source: "repo" };
44
52
  }
45
53
 
46
- // Records each applied message's proto type name; every controller resolves to
47
- // the same stub apply (the handlers only call `.apply`).
48
- function recordingDeps() {
54
+ // Records each applied message (and its proto type name); every controller
55
+ // resolves to the same stub apply (the handlers only call `.apply`).
56
+ function recordingDeps(stigmer?: unknown) {
49
57
  const applied: string[] = [];
58
+ const appliedMessages: Message[] = [];
50
59
  const controller = (() => ({
51
60
  apply: async (m: Message) => {
52
61
  applied.push((m as { $typeName: string }).$typeName);
62
+ appliedMessages.push(m);
53
63
  return m;
54
64
  },
55
65
  })) as unknown as ControllerFn;
56
66
  return {
57
67
  applied,
68
+ appliedMessages,
58
69
  deps: {
59
70
  controller,
60
- stigmer: {} as never,
71
+ stigmer: (stigmer ?? {}) as never,
61
72
  info: () => {},
62
73
  warn: () => {},
63
74
  },
64
75
  };
65
76
  }
66
77
 
78
+ // A Stigmer stub whose project.getByReference reports the given stored hash
79
+ // (null → the label is absent; "absent" → the project itself does not exist).
80
+ function stigmerWithServerHash(stored: string | null | "absent"): Stigmer {
81
+ return {
82
+ project: {
83
+ getByReference: async () => {
84
+ if (stored === "absent") throw new Error("not found");
85
+ return { metadata: { labels: stored === null ? {} : { [SEEDPACK_HASH_LABEL]: stored } } };
86
+ },
87
+ },
88
+ } as unknown as Stigmer;
89
+ }
90
+
67
91
  let content: SeedpackContent;
68
92
  let markerDir: string;
69
93
  beforeEach(() => {
@@ -125,6 +149,91 @@ describe("applySeedpack", () => {
125
149
  expect(result.applied).toBe(true);
126
150
  expect(forced.applied.length).toBeGreaterThan(0);
127
151
  });
152
+
153
+ it("stamps the content hash as a reserved label on the applied Project", async () => {
154
+ const { appliedMessages, deps } = recordingDeps();
155
+ const result = await applySeedpack(deps, { markerDir, content });
156
+
157
+ const project = appliedMessages.find((m) => (m as { $typeName: string }).$typeName.endsWith("Project")) as
158
+ | { metadata?: { labels: Record<string, string> } }
159
+ | undefined;
160
+ expect(project).toBeDefined();
161
+ expect(project?.metadata?.labels[SEEDPACK_HASH_LABEL]).toBe(result.hash);
162
+ });
163
+ });
164
+
165
+ describe("applySeedpack — cloud mode (useServerHash)", () => {
166
+ it("skips when the server's recorded hash matches, without touching the marker", async () => {
167
+ const hash = seedpackContentHash({ content });
168
+ const { applied, deps } = recordingDeps(stigmerWithServerHash(hash));
169
+
170
+ const result = await applySeedpack(deps, { markerDir, content, useServerHash: true });
171
+
172
+ expect(result.applied).toBe(false);
173
+ expect(applied).toHaveLength(0);
174
+ expect(readMarker(markerDir)).toBeNull();
175
+ });
176
+
177
+ it("applies when the server records a different hash — and never writes the marker", async () => {
178
+ const { applied, deps } = recordingDeps(stigmerWithServerHash("sha256:0000000000000000"));
179
+
180
+ const result = await applySeedpack(deps, { markerDir, content, useServerHash: true });
181
+
182
+ expect(result.applied).toBe(true);
183
+ expect(applied.length).toBeGreaterThan(0);
184
+ // Cloud mode's record is the Project label; a marker here would be a
185
+ // second record of the same fact, free to drift.
186
+ expect(readMarker(markerDir)).toBeNull();
187
+ });
188
+
189
+ it("applies when the seedpack Project does not exist yet (first bootstrap)", async () => {
190
+ const { applied, deps } = recordingDeps(stigmerWithServerHash("absent"));
191
+
192
+ const result = await applySeedpack(deps, { markerDir, content, useServerHash: true });
193
+
194
+ expect(result.applied).toBe(true);
195
+ expect(applied.length).toBeGreaterThan(0);
196
+ });
197
+
198
+ it("ignores a stale local marker: the server is the only truth consulted", async () => {
199
+ const hash = seedpackContentHash({ content });
200
+ // A leftover marker from the pre-cloud#429 era says "up to date"…
201
+ const { deps: first } = recordingDeps();
202
+ await applySeedpack(first, { markerDir, content });
203
+ // …but the server has never seen this content.
204
+ const { applied, deps } = recordingDeps(stigmerWithServerHash(null));
205
+
206
+ const result = await applySeedpack(deps, { markerDir, content, useServerHash: true });
207
+
208
+ expect(result.applied).toBe(true);
209
+ expect(applied.length).toBeGreaterThan(0);
210
+ expect(hash).toBe(result.hash);
211
+ });
212
+
213
+ it("re-applies when forced even if the server hash matches", async () => {
214
+ const hash = seedpackContentHash({ content });
215
+ const { applied, deps } = recordingDeps(stigmerWithServerHash(hash));
216
+
217
+ const result = await applySeedpack(deps, { markerDir, content, useServerHash: true, force: true });
218
+
219
+ expect(result.applied).toBe(true);
220
+ expect(applied.length).toBeGreaterThan(0);
221
+ });
222
+ });
223
+
224
+ describe("readServerSeedpackHash", () => {
225
+ it("returns the recorded label", async () => {
226
+ const hash = await readServerSeedpackHash(stigmerWithServerHash("sha256:abcdef0123456789"), "stigmer");
227
+ expect(hash).toBe("sha256:abcdef0123456789");
228
+ });
229
+
230
+ it("returns null when the label is absent", async () => {
231
+ expect(await readServerSeedpackHash(stigmerWithServerHash(null), "stigmer")).toBeNull();
232
+ });
233
+
234
+ it("returns null when the project cannot be read (never applied / transient failure)", async () => {
235
+ expect(await readServerSeedpackHash(stigmerWithServerHash("absent"), "stigmer")).toBeNull();
236
+ });
128
237
  });
129
238
 
130
239
  describe("seedpackContentHash", () => {
@@ -139,6 +248,18 @@ describe("seedpackContentHash", () => {
139
248
  // CI canary manifest) must live outside the content set, never beside the
140
249
  // McpServer YAMLs where `loadDocuments(strict)` would reject it for missing a
141
250
  // `kind`. The synthetic fixtures above can't catch this — only real content can.
251
+ // The cloud-mode hash read addresses the seedpack Project by a pinned slug.
252
+ // The server derives the slug from the manifest's metadata.name, so this pin
253
+ // holds exactly as long as the real seedpack keeps that name — verify against
254
+ // the real content so a rename there fails here, not silently in prod.
255
+ describe("SEEDPACK_PROJECT_SLUG pins the real manifest name", () => {
256
+ it("matches seedpack/stigmer.yaml metadata.name", () => {
257
+ const real = resolveSeedpackContent();
258
+ const manifest = readFileSync(join(real.dir, "stigmer.yaml"), "utf8");
259
+ expect(manifest).toMatch(new RegExp(`name:\\s*${SEEDPACK_PROJECT_SLUG}\\s*$`, "m"));
260
+ });
261
+ });
262
+
142
263
  describe("real seedpack content loads through the declarative scanner", () => {
143
264
  it("scans + builds apply items without a missing-kind error", () => {
144
265
  const real = resolveSeedpackContent();
@@ -10,11 +10,18 @@
10
10
  // 2. the project (stigmer.yaml + agents/skills/mcp-servers/workflows) via the
11
11
  // declarative reconciler, under the target org.
12
12
  //
13
- // Idempotent: a content-hash marker skips the apply when nothing changed.
13
+ // Idempotent, with the applied-hash truth living with the backend it describes
14
+ // (cloud#429): every apply stamps the content hash as a reserved label on the
15
+ // seedpack Project, and cloud-mode runs skip by reading that label back — so
16
+ // any machine (operator laptop, CI) sees the same applied state. Local mode
17
+ // keeps the original marker file (the local data dir lives and dies with the
18
+ // local backend, so a local file IS backend-scoped state there).
14
19
 
15
20
  import { mkdtempSync, rmSync } from "node:fs";
16
21
  import { tmpdir } from "node:os";
17
22
  import { join } from "node:path";
23
+ import { create } from "@bufbuild/protobuf";
24
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
18
25
  import type { Stigmer } from "@stigmer/sdk";
19
26
  import { applyItem, resolveApplyItems } from "../../resources/apply/apply.js";
20
27
  import { applyDeclarative, detectTrack } from "../../resources/apply/declarative.js";
@@ -31,6 +38,23 @@ import {
31
38
  const DEFAULT_ORG = "stigmer";
32
39
  const ORG_ENV_VAR = "STIGMER_SEEDPACK_ORG";
33
40
 
41
+ /**
42
+ * Reserved label on the seedpack Project recording the last applied content
43
+ * hash (cloud#429). Writing a NEW value requires `can_write_reserved_labels`
44
+ * (the seeding identity's capability); re-sending an unchanged value is an
45
+ * echo the cloud guard passes for any caller — so re-applies of identical
46
+ * content are never treated as reserved-label writes.
47
+ */
48
+ export const SEEDPACK_HASH_LABEL = "stigmer.ai/seedpack-hash";
49
+
50
+ /**
51
+ * The seedpack Project's slug. The server derives it from the manifest's
52
+ * `metadata.name` in `seedpack/stigmer.yaml` — "stigmer-seedpack" is already
53
+ * slug-shaped, so name and slug coincide. Pinned by a test against the real
54
+ * content so a rename there fails loudly here.
55
+ */
56
+ export const SEEDPACK_PROJECT_SLUG = "stigmer-seedpack";
57
+
34
58
  export interface SeedpackApplyDeps {
35
59
  /** Raw command-controller accessor (full-proto apply, preserves metadata.id). */
36
60
  readonly controller: ControllerFn;
@@ -43,7 +67,7 @@ export interface SeedpackApplyDeps {
43
67
  }
44
68
 
45
69
  export interface SeedpackApplyOptions {
46
- /** Directory holding the idempotency marker (data dir for local, config dir for cloud). */
70
+ /** Directory holding the idempotency marker (local mode only). */
47
71
  readonly markerDir: string;
48
72
  /** Target org slug. Defaults to STIGMER_SEEDPACK_ORG, then "stigmer". */
49
73
  readonly org?: string;
@@ -52,6 +76,13 @@ export interface SeedpackApplyOptions {
52
76
  /** Pre-resolved content (injectable for tests); resolved on demand otherwise. */
53
77
  readonly content?: SeedpackContent;
54
78
  readonly home?: string;
79
+ /**
80
+ * Cloud mode: the idempotency truth is the {@link SEEDPACK_HASH_LABEL} on the
81
+ * server's seedpack Project, read via {@link readServerSeedpackHash} — the
82
+ * local marker is neither read nor written, so a stateless machine (CI, a
83
+ * second operator laptop) still skips unchanged content correctly.
84
+ */
85
+ readonly useServerHash?: boolean;
55
86
  }
56
87
 
57
88
  export interface SeedpackApplyResult {
@@ -77,17 +108,35 @@ export function seedpackContentHash(opts: Pick<SeedpackApplyOptions, "content" |
77
108
  return hashSeedpackContent(content.dir);
78
109
  }
79
110
 
111
+ /**
112
+ * Read the applied content hash recorded on the backend's seedpack Project,
113
+ * or null when it cannot be proven (project absent — never applied — or the
114
+ * read failed). Null means "apply": the apply itself is idempotent server-side,
115
+ * so an unprovable state costs one redundant pass, never a skipped update.
116
+ */
117
+ export async function readServerSeedpackHash(stigmer: Stigmer, org: string): Promise<string | null> {
118
+ try {
119
+ const project = await stigmer.project.getByReference({ org, slug: SEEDPACK_PROJECT_SLUG });
120
+ return project.metadata?.labels?.[SEEDPACK_HASH_LABEL] ?? null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
80
126
  /**
81
127
  * Apply the seedpack to the backend the deps connect to. Returns `applied:false`
82
- * (without touching the backend) when the marker already matches the content
83
- * hash and `force` is not set.
128
+ * (without touching the backend beyond the cloud-mode hash read) when the
129
+ * recorded hash already matches the content hash and `force` is not set.
84
130
  */
85
131
  export async function applySeedpack(deps: SeedpackApplyDeps, opts: SeedpackApplyOptions): Promise<SeedpackApplyResult> {
86
132
  const org = resolveSeedpackOrg(opts.org);
87
133
  const content = opts.content ?? resolveSeedpackContent({ home: opts.home });
88
134
  const hash = hashSeedpackContent(content.dir);
89
135
 
90
- if (opts.force !== true && readMarker(opts.markerDir) === hash) {
136
+ const appliedHash = opts.useServerHash === true
137
+ ? await readServerSeedpackHash(deps.stigmer, org)
138
+ : readMarker(opts.markerDir);
139
+ if (opts.force !== true && appliedHash === hash) {
91
140
  return { applied: false, hash, org };
92
141
  }
93
142
 
@@ -96,8 +145,12 @@ export async function applySeedpack(deps: SeedpackApplyDeps, opts: SeedpackApply
96
145
  try {
97
146
  extractSeedpack(content.dir, stage);
98
147
  await applyOrganizations(deps, stage);
99
- await applyProject(deps, stage, org);
100
- writeMarker(opts.markerDir, hash);
148
+ await applyProject(deps, stage, org, hash);
149
+ // The Project label written above is the cloud-mode record; the marker
150
+ // stays the local-mode one. Never both — two records of one fact drift.
151
+ if (opts.useServerHash !== true) {
152
+ writeMarker(opts.markerDir, hash);
153
+ }
101
154
  } finally {
102
155
  rmSync(stage, { recursive: true, force: true });
103
156
  }
@@ -124,11 +177,18 @@ async function applyOrganizations(deps: SeedpackApplyDeps, stageDir: string): Pr
124
177
 
125
178
  // Phase 2: the project (agents, skills, MCP servers, workflows) via the shared
126
179
  // declarative reconciler — the same path `stigmer apply` runs for a user project.
127
- async function applyProject(deps: SeedpackApplyDeps, stageDir: string, org: string): Promise<void> {
180
+ //
181
+ // The content hash is stamped as a reserved label on the Project HERE, in the
182
+ // seedpack path only — general `stigmer apply` never writes reserved labels.
183
+ // The stamp rides the project apply that happens anyway, so recording the hash
184
+ // costs no extra RPC and can never succeed while the apply failed.
185
+ async function applyProject(deps: SeedpackApplyDeps, stageDir: string, org: string, contentHash: string): Promise<void> {
128
186
  const detect = detectTrack(stageDir);
129
- if (detect.track !== "declarative") {
187
+ if (detect.track !== "declarative" || detect.project === undefined) {
130
188
  throw new Error(`seedpack is not a declarative project (detected '${detect.track}')`);
131
189
  }
190
+ detect.project.metadata ??= create(ApiResourceMetadataSchema, {});
191
+ detect.project.metadata.labels[SEEDPACK_HASH_LABEL] = contentHash;
132
192
  await applyDeclarative(detect, {
133
193
  controller: deps.controller,
134
194
  stigmer: deps.stigmer,
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
3
3
  import { APPLY_HANDLERS } from "../resources/apply/handlers.js";
4
4
  import { DELETE_HANDLERS } from "../resources/delete.js";
5
5
  import { GET_BINDINGS } from "../resources/get-bindings.js";
6
+ import { isExecutionAlias } from "../resources/execution.js";
6
7
  import { LIST_HANDLERS, SEARCH_KINDS } from "../resources/list.js";
7
8
  import { VALIDATE_SCHEMAS } from "../resources/validate.js";
8
9
  import { normalizeAlias } from "./aliases.js";
@@ -55,8 +56,14 @@ describe("registry — alias resolution", () => {
55
56
  expect(registry.getByAlias("nope")).toBeUndefined();
56
57
  });
57
58
 
58
- it("does not register agent_execution as an addressable type", () => {
59
- expect(registry.getByKind(ApiResourceKind.agent_execution)).toBeUndefined();
59
+ // The runtime execution kinds are served by the pre-gate `list executions`
60
+ // route and their dedicated controllers; registering either would put a
61
+ // row in `list types` that the pre-gate route then shadows — the
62
+ // stigmer/stigmer#469 class (see the alias-shadowing suite below).
63
+ it("does not register the runtime execution kinds as addressable types", () => {
64
+ for (const kind of [ApiResourceKind.agent_execution, ApiResourceKind.workflow_execution]) {
65
+ expect(registry.getByKind(kind)).toBeUndefined();
66
+ }
60
67
  });
61
68
  });
62
69
 
@@ -102,12 +109,18 @@ describe("registry — verb support matrix", () => {
102
109
  expect(registry.supportsVerb(ApiResourceKind.schedule, Verb.Run)).toBe(false);
103
110
  });
104
111
 
105
- it("session and agent_share promise only apply (narrowed — stigmer/stigmer#354)", () => {
106
- for (const kind of [ApiResourceKind.session, ApiResourceKind.agent_share]) {
107
- expect(registry.supportsVerb(kind, Verb.Apply)).toBe(true);
108
- for (const v of [Verb.Get, Verb.List, Verb.Delete]) {
109
- expect(registry.supportsVerb(kind, v)).toBe(false);
110
- }
112
+ it("agent_share promises only apply (narrowed — stigmer/stigmer#354)", () => {
113
+ expect(registry.supportsVerb(ApiResourceKind.agent_share, Verb.Apply)).toBe(true);
114
+ for (const v of [Verb.Get, Verb.List, Verb.Delete]) {
115
+ expect(registry.supportsVerb(ApiResourceKind.agent_share, v)).toBe(false);
116
+ }
117
+ });
118
+
119
+ it("session promises apply + list; get/delete stay narrowed (stigmer/stigmer#354, #469)", () => {
120
+ expect(registry.supportsVerb(ApiResourceKind.session, Verb.Apply)).toBe(true);
121
+ expect(registry.supportsVerb(ApiResourceKind.session, Verb.List)).toBe(true);
122
+ for (const v of [Verb.Get, Verb.Delete]) {
123
+ expect(registry.supportsVerb(ApiResourceKind.session, v)).toBe(false);
111
124
  }
112
125
  });
113
126
 
@@ -268,3 +281,32 @@ describe("registry — verb/dispatch conformance", () => {
268
281
  }
269
282
  });
270
283
  });
284
+
285
+ // `stigmer list` resolves a handful of aliases BEFORE consulting the
286
+ // registry (commands/list.ts): `types` (a command word, not a kind) and the
287
+ // execution family. A pre-gate alias that intercepts a REGISTERED kind hides
288
+ // that kind's registry dispatch behind bespoke behavior the verb matrix
289
+ // cannot describe — sessions shipped working-but-unadvertised for two months
290
+ // exactly this way (stigmer/stigmer#469). Every pre-gate predicate must be
291
+ // listed here; the pin holds each one away from every registered kind's
292
+ // alias set, so a future bypass for an addressable kind is a red test, not
293
+ // a silently lying `list types` row.
294
+ describe("registry — pre-gate list aliases cannot shadow registered kinds", () => {
295
+ const PRE_GATE_PREDICATES: ReadonlyArray<{ name: string; matches: (type: string) => boolean }> = [
296
+ { name: "executions (resources/execution.ts isExecutionAlias)", matches: isExecutionAlias },
297
+ ];
298
+
299
+ it("no registered kind's alias is intercepted by a pre-gate predicate", () => {
300
+ for (const { name, matches } of PRE_GATE_PREDICATES) {
301
+ for (const info of registry.all()) {
302
+ for (const alias of info.aliases) {
303
+ expect(
304
+ matches(alias),
305
+ `registry alias '${alias}' (${info.name}) is intercepted by the pre-gate route ${name} — ` +
306
+ "it shadows the kind's registry dispatch (the stigmer/stigmer#469 works-but-unadvertised class)",
307
+ ).toBe(false);
308
+ }
309
+ }
310
+ }
311
+ });
312
+ });
@@ -57,9 +57,12 @@ export const VERB_SUPPORT: ReadonlyMap<ApiResourceKind, ReadonlySet<Verb>> = new
57
57
  [ApiResourceKind.agent_instance, new Set<Verb>([Verb.Apply, Verb.Get, Verb.List, Verb.Delete])],
58
58
  // No list: proto exposes getByWorkflow (requires workflow_id), not a generic list.
59
59
  [ApiResourceKind.workflow_instance, new Set<Verb>([Verb.Apply, Verb.Get, Verb.Delete])],
60
- // Sessions are runtime conversation state with no CLI read/ops story yet;
61
- // get/list/delete are deliberately not promised (stigmer/stigmer#354).
62
- [ApiResourceKind.session, new Set<Verb>([Verb.Apply])],
60
+ // Session list is served by its dedicated query RPC (a LIST_HANDLERS
61
+ // entry); stigmer/stigmer#469 promoted it into the matrix after it shipped
62
+ // working-but-unadvertised through a pre-gate bypass. get/delete remain
63
+ // deliberately unpromised until a real read/ops story exists
64
+ // (stigmer/stigmer#354) — resume-style flows go through `stigmer session`.
65
+ [ApiResourceKind.session, new Set<Verb>([Verb.Apply, Verb.List])],
63
66
  ]);
64
67
 
65
68
  export function verbsForKind(kind: ApiResourceKind): ReadonlySet<Verb> {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { create, fromJson, type JsonValue, type Message } from "@bufbuild/protobuf";
10
10
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
11
+ import type { Workflow } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
11
12
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
12
13
  import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
13
14
  import { UpdateVisibilityInputSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
@@ -19,6 +20,7 @@ import { UsageError } from "../../errors/index.js";
19
20
  import { CommandResult } from "../../output/index.js";
20
21
  import { defaultRegistry, Verb } from "../../registry/index.js";
21
22
  import { loadDocuments, resolveYamlFiles } from "../documents.js";
23
+ import { decodeWorkflowTaskConfigs } from "../task-configs.js";
22
24
  import { type ApplyHandler, APPLY_HANDLERS, type ControllerFn } from "./handlers.js";
23
25
 
24
26
  export interface ApplyItem {
@@ -112,7 +114,15 @@ export async function applyItem(
112
114
  */
113
115
  export function marshalItem(item: ApplyItem): Message {
114
116
  try {
115
- return fromJson(item.handler.schema, item.document, { ignoreUnknownFields: false });
117
+ const message = fromJson(item.handler.schema, item.document, { ignoreUnknownFields: false });
118
+ // Workflow task_config blocks live inside an open Struct the top-level
119
+ // decode cannot see into; decode them per kind so a dry-run (and the
120
+ // reconciler's marshal) rejects exactly what a real apply rejects
121
+ // (stigmer/stigmer#778).
122
+ if (item.handler.kind === ApiResourceKind.workflow) {
123
+ decodeWorkflowTaskConfigs(message as Workflow);
124
+ }
125
+ return message;
116
126
  } catch (err) {
117
127
  throw new UsageError(`invalid ${item.handler.displayName} in ${item.filePath}: ${(err as Error).message}`);
118
128
  }