@stigmer/cli 3.2.3 → 3.4.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 (44) hide show
  1. package/commands/delete.js +5 -2
  2. package/commands/delete.js.map +1 -1
  3. package/commands/mcp-server.d.ts.map +1 -1
  4. package/commands/mcp-server.js +1 -0
  5. package/commands/mcp-server.js.map +1 -1
  6. package/package.json +5 -5
  7. package/registry/metadata.d.ts.map +1 -1
  8. package/registry/metadata.js +2 -0
  9. package/registry/metadata.js.map +1 -1
  10. package/registry/verb-support.d.ts.map +1 -1
  11. package/registry/verb-support.js +4 -0
  12. package/registry/verb-support.js.map +1 -1
  13. package/resources/apply/apply.d.ts.map +1 -1
  14. package/resources/apply/apply.js +13 -10
  15. package/resources/apply/apply.js.map +1 -1
  16. package/resources/apply/handlers.d.ts.map +1 -1
  17. package/resources/apply/handlers.js +11 -0
  18. package/resources/apply/handlers.js.map +1 -1
  19. package/resources/delete.d.ts +5 -1
  20. package/resources/delete.d.ts.map +1 -1
  21. package/resources/delete.js +40 -8
  22. package/resources/delete.js.map +1 -1
  23. package/resources/get-bindings.d.ts.map +1 -1
  24. package/resources/get-bindings.js +8 -0
  25. package/resources/get-bindings.js.map +1 -1
  26. package/resources/list.d.ts.map +1 -1
  27. package/resources/list.js +1 -0
  28. package/resources/list.js.map +1 -1
  29. package/resources/validate.d.ts.map +1 -1
  30. package/resources/validate.js +2 -0
  31. package/resources/validate.js.map +1 -1
  32. package/src/commands/delete.ts +5 -2
  33. package/src/commands/mcp-server.ts +1 -0
  34. package/src/registry/metadata.ts +2 -0
  35. package/src/registry/verb-support.ts +4 -0
  36. package/src/resources/apply/apply.integration.test.ts +69 -0
  37. package/src/resources/apply/apply.ts +13 -10
  38. package/src/resources/apply/handlers.ts +11 -0
  39. package/src/resources/delete.ts +48 -9
  40. package/src/resources/get-bindings.ts +8 -0
  41. package/src/resources/list.ts +1 -0
  42. package/src/resources/mutate-verbs.integration.test.ts +178 -1
  43. package/src/resources/read-verbs.integration.test.ts +111 -1
  44. package/src/resources/validate.ts +2 -0
@@ -35,6 +35,7 @@ Environment variables:
35
35
  STIGMER_SERVER_ADDRESS gRPC address (default: CLI config, else localhost:7234)
36
36
  STIGMER_API_KEY API key (auto-resolved from CLI config for cloud)
37
37
  STIGMER_MCP_TRANSPORT stdio | http | both (default: stdio)
38
+ STIGMER_MCP_ROSTER full | records (default: full; records = the five record tools only)
38
39
  STIGMER_MCP_HTTP_PORT HTTP listen port (default: 8080)
39
40
  STIGMER_MCP_LOG_FORMAT text | json (default: text)
40
41
  STIGMER_MCP_LOG_LEVEL debug | info | warn | error (default: info)
@@ -35,6 +35,7 @@ export const KIND_META: ReadonlyMap<ApiResourceKind, KindMeta> = new Map([
35
35
  [ApiResourceKind.agent_share, { name: "AgentShare", displayName: "Agent Share", idPrefix: "ash" }],
36
36
  [ApiResourceKind.agent_channel, { name: "AgentChannel", displayName: "Agent Channel", idPrefix: "ach" }],
37
37
  [ApiResourceKind.channel_app, { name: "ChannelApp", displayName: "Channel App", idPrefix: "chapp" }],
38
+ [ApiResourceKind.datastore, { name: "Datastore", displayName: "Datastore", idPrefix: "dst" }],
38
39
  [ApiResourceKind.agent_instance, { name: "AgentInstance", displayName: "Agent Instance", idPrefix: "ain" }],
39
40
  [ApiResourceKind.workflow_instance, { name: "WorkflowInstance", displayName: "Workflow Instance", idPrefix: "win" }],
40
41
  [ApiResourceKind.session, { name: "Session", displayName: "Session", idPrefix: "ses" }],
@@ -60,6 +61,7 @@ export const CLI_RELEVANT_KINDS: readonly ApiResourceKind[] = [
60
61
  ApiResourceKind.agent_share,
61
62
  ApiResourceKind.agent_channel,
62
63
  ApiResourceKind.channel_app,
64
+ ApiResourceKind.datastore,
63
65
  ApiResourceKind.agent_instance,
64
66
  ApiResourceKind.workflow_instance,
65
67
  ApiResourceKind.session,
@@ -48,6 +48,10 @@ export const VERB_SUPPORT: ReadonlyMap<ApiResourceKind, ReadonlySet<Verb>> = new
48
48
  // Secret fields round-trip as ***REDACTED***; applying a fetched manifest
49
49
  // preserves the stored secrets (the OAuthApp marker convention).
50
50
  [ApiResourceKind.channel_app, new Set<Verb>([Verb.Apply, Verb.Get, Verb.List, Verb.Delete])],
51
+ // Record reads/writes are agent tools and record RPCs, not CLI verbs; the
52
+ // generic verbs cover the declarative path (apply a manifest, inspect,
53
+ // tear down — delete is guarded server-side for non-empty datastores).
54
+ [ApiResourceKind.datastore, new Set<Verb>([Verb.Apply, Verb.Validate, Verb.Get, Verb.List, Verb.Delete])],
51
55
  [ApiResourceKind.agent_instance, new Set<Verb>([Verb.Apply, Verb.Get, Verb.List, Verb.Delete])],
52
56
  // No list: proto exposes getByWorkflow (requires workflow_id), not a generic list.
53
57
  [ApiResourceKind.workflow_instance, new Set<Verb>([Verb.Apply, Verb.Get, Verb.Delete])],
@@ -17,6 +17,8 @@ import { type ConnectRouter, createClient } from "@connectrpc/connect";
17
17
  import { connectNodeAdapter } from "@connectrpc/connect-node";
18
18
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
19
19
  import { AgentCommandController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/command_pb";
20
+ import type { Datastore } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/api_pb";
21
+ import { DatastoreCommandController } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/command_pb";
20
22
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
21
23
  import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
22
24
  import { createNodeTransport, normalizeEndpoint } from "@stigmer/sdk/node";
@@ -30,10 +32,12 @@ const openSessions = new Set<ServerHttp2Session>();
30
32
 
31
33
  let appliedAgents: Agent[] = [];
32
34
  let appliedMcps: McpServer[] = [];
35
+ let appliedDatastores: Datastore[] = [];
33
36
 
34
37
  beforeEach(() => {
35
38
  appliedAgents = [];
36
39
  appliedMcps = [];
40
+ appliedDatastores = [];
37
41
  });
38
42
 
39
43
  function writeYaml(dir: string, name: string, body: string): string {
@@ -56,6 +60,12 @@ beforeAll(async () => {
56
60
  return req;
57
61
  },
58
62
  });
63
+ router.service(DatastoreCommandController, {
64
+ apply: (req) => {
65
+ appliedDatastores.push(req);
66
+ return req;
67
+ },
68
+ });
59
69
  };
60
70
 
61
71
  backend = createHttp2Server(connectNodeAdapter({ routes }));
@@ -146,6 +156,65 @@ describe("file-mode apply — fidelity (raw controller preserves full proto)", (
146
156
  });
147
157
  });
148
158
 
159
+ const DATASTORE_YAML = [
160
+ "apiVersion: agentic.stigmer.ai/v1",
161
+ "kind: Datastore",
162
+ "metadata:",
163
+ " name: clinic-records",
164
+ " slug: clinic-records",
165
+ "spec:",
166
+ " timezone: Asia/Kolkata",
167
+ " authorization:",
168
+ " roles:",
169
+ " - name: patient",
170
+ " default_role: patient",
171
+ " collections:",
172
+ " - name: bookings",
173
+ " fields:",
174
+ " - name: slot_start",
175
+ " type: timestamp",
176
+ " required: true",
177
+ " uniques:",
178
+ " - name: one_per_slot",
179
+ " fields: [slot_start]",
180
+ ' message: "that slot is already booked"',
181
+ "",
182
+ ].join("\n");
183
+
184
+ describe("file-mode apply — datastore", () => {
185
+ it("applies a Datastore through the raw controller with the spec intact", async () => {
186
+ const dir = mkdtempSync(join(tmpdir(), "apply-it-"));
187
+ try {
188
+ writeYaml(dir, "datastore.yaml", DATASTORE_YAML);
189
+ const items = resolveApplyItems(dir);
190
+ expect(items).toHaveLength(1);
191
+
192
+ const outcome = await applyItem(controllerFn, items[0], "acme", false);
193
+ expect(outcome.result.message).toBe("Datastore created successfully");
194
+
195
+ expect(appliedDatastores).toHaveLength(1);
196
+ const spec = appliedDatastores[0].spec;
197
+ expect(spec?.timezone).toBe("Asia/Kolkata");
198
+ expect(spec?.authorization?.defaultRole).toBe("patient");
199
+ expect(spec?.collections[0]?.uniques[0]?.message).toBe("that slot is already booked");
200
+ } finally {
201
+ rmSync(dir, { recursive: true, force: true });
202
+ }
203
+ });
204
+
205
+ it("sorts a Datastore before the Agent that references it", () => {
206
+ const dir = mkdtempSync(join(tmpdir(), "apply-it-"));
207
+ try {
208
+ writeYaml(dir, "agent.yaml", AGENT_WITH_ID);
209
+ writeYaml(dir, "datastore.yaml", DATASTORE_YAML);
210
+ const items = resolveApplyItems(dir);
211
+ expect(items.map((i) => i.handler.displayName)).toEqual(["Datastore", "Agent"]);
212
+ } finally {
213
+ rmSync(dir, { recursive: true, force: true });
214
+ }
215
+ });
216
+ });
217
+
149
218
  describe("file-mode apply — orchestration", () => {
150
219
  it("sorts items into dependency order (mcp_server before agent)", () => {
151
220
  const dir = mkdtempSync(join(tmpdir(), "apply-it-"));
@@ -44,16 +44,19 @@ export interface ApplyOutcome {
44
44
  const APPLY_ORDER: ReadonlyMap<ApiResourceKind, number> = new Map([
45
45
  [ApiResourceKind.organization, 0],
46
46
  [ApiResourceKind.mcp_server, 1],
47
- [ApiResourceKind.agent, 2],
48
- [ApiResourceKind.workflow, 3],
49
- [ApiResourceKind.environment, 4],
50
- [ApiResourceKind.identity_provider, 5],
51
- [ApiResourceKind.oauth_app, 6],
52
- [ApiResourceKind.agent_instance, 7],
53
- [ApiResourceKind.workflow_instance, 8],
54
- [ApiResourceKind.session, 9],
55
- // After agent (2) and environment (4): a share references both.
56
- [ApiResourceKind.agent_share, 10],
47
+ // Before agent: an Agent references a Datastore via datastore_usages
48
+ // (the McpServer-before-Agent pattern; mirrors the SDK manifest registry).
49
+ [ApiResourceKind.datastore, 2],
50
+ [ApiResourceKind.agent, 3],
51
+ [ApiResourceKind.workflow, 4],
52
+ [ApiResourceKind.environment, 5],
53
+ [ApiResourceKind.identity_provider, 6],
54
+ [ApiResourceKind.oauth_app, 7],
55
+ [ApiResourceKind.agent_instance, 8],
56
+ [ApiResourceKind.workflow_instance, 9],
57
+ [ApiResourceKind.session, 10],
58
+ // After agent and environment: a share references both.
59
+ [ApiResourceKind.agent_share, 11],
57
60
  ]);
58
61
 
59
62
  /** Expand a path into ordered, kind-resolved apply items (strict YAML parse). */
@@ -23,6 +23,8 @@ import { type AgentInstance, AgentInstanceSchema } from "@stigmer/protos/ai/stig
23
23
  import { AgentInstanceCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/command_pb";
24
24
  import { type AgentShare, AgentShareSchema } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
25
25
  import { AgentShareCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/command_pb";
26
+ import { type Datastore, DatastoreSchema } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/api_pb";
27
+ import { DatastoreCommandController } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/command_pb";
26
28
  import { type Environment, EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
27
29
  import { EnvironmentCommandController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/command_pb";
28
30
  import { type McpServer, McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
@@ -74,6 +76,15 @@ export const APPLY_HANDLERS: ReadonlyMap<ApiResourceKind, ApplyHandler> = new Ma
74
76
  apply: (c, m) => c(McpServerCommandController).apply(m as McpServer),
75
77
  },
76
78
  ],
79
+ [
80
+ ApiResourceKind.datastore,
81
+ {
82
+ kind: ApiResourceKind.datastore,
83
+ displayName: "Datastore",
84
+ schema: DatastoreSchema,
85
+ apply: (c, m) => c(DatastoreCommandController).apply(m as Datastore),
86
+ },
87
+ ],
77
88
  [
78
89
  ApiResourceKind.agent,
79
90
  {
@@ -45,33 +45,60 @@ interface HasMetadata {
45
45
  readonly metadata?: { readonly id?: string; readonly name?: string; readonly slug?: string; readonly org?: string };
46
46
  }
47
47
 
48
- type DeleteFn = (client: Stigmer, id: string) => Promise<HasMetadata>;
48
+ // `force` is the RPC-level acknowledgment of destructive side effects (e.g. a
49
+ // datastore's held records) — distinct from, but carried by, the CLI's
50
+ // `-f/--force` flag. Kinds whose delete RPC takes a plain typed ID have no
51
+ // force field and simply ignore the argument.
52
+ type DeleteFn = (client: Stigmer, id: string, force: boolean) => Promise<HasMetadata>;
49
53
 
50
54
  // Kinds the unified delete handles directly, each bound to its SDK delete call.
51
55
  // Kinds that carry a Delete verb but no entry here (identity_provider,
52
- // oauth_app, environment, workflow_instance, session) fall through to a
56
+ // oauth_app, agent_share, workflow_instance, session) fall through to a
53
57
  // "not implemented" usage error, matching Go's default branch.
54
- // McpServer is the outlier whose delete takes a DeleteResourceInput, not an ID.
58
+ // Kinds whose delete takes a DeleteResourceInput (not an ID) thread the force
59
+ // acknowledgment through; today only the datastore server honors it (the
60
+ // non-empty guard), but force is the contract's generic ack carrier.
55
61
  const DELETE_HANDLERS: ReadonlyMap<ApiResourceKind, DeleteFn> = new Map<ApiResourceKind, DeleteFn>([
56
62
  [ApiResourceKind.agent, (c, id) => c.agent.delete(id)],
57
63
  [ApiResourceKind.agent_instance, (c, id) => c.agentInstance.delete(id)],
58
64
  [ApiResourceKind.workflow, (c, id) => c.workflow.delete(id)],
59
- [ApiResourceKind.mcp_server, (c, id) => c.mcpServer.delete({ resourceId: id })],
65
+ [ApiResourceKind.mcp_server, (c, id, force) => c.mcpServer.delete({ resourceId: id, force })],
60
66
  [ApiResourceKind.project, (c, id) => c.project.delete(id)],
67
+ [ApiResourceKind.datastore, (c, id, force) => c.datastore.delete({ resourceId: id, force })],
68
+ [ApiResourceKind.environment, (c, id, force) => c.environment.delete({ resourceId: id, force })],
69
+ [ApiResourceKind.agent_channel, (c, id) => c.agentChannel.delete(id)],
70
+ [ApiResourceKind.channel_app, (c, id, force) => c.channelapp.delete({ resourceId: id, force })],
61
71
  [ApiResourceKind.skill, (c, id) => c.skill.delete(id)],
62
72
  [ApiResourceKind.api_key, (c, id) => c.apiKey.delete(id)],
63
73
  ]);
64
74
 
75
+ // The deletable-types list for the unknown-type error, derived from the
76
+ // handler map (plus the two special-cased routes) so it can never drift from
77
+ // what the dispatch below actually supports. Deliberately NOT derived from the
78
+ // registry's verb matrix — that declares Delete for kinds this dispatch does
79
+ // not wire, and listing those would over-promise.
80
+ function availableDeleteTypes(): string {
81
+ const wired = [...DELETE_HANDLERS.keys()]
82
+ .map((kind) => defaultRegistry().getByKind(kind)?.singular)
83
+ .filter((singular): singular is string => singular !== undefined);
84
+ return [...wired, "execution", "organization"].join(", ");
85
+ }
86
+
65
87
  /**
66
88
  * Build the delete plan for a `<type> <reference>` pair. Routes the three
67
89
  * special cases first, then the registry-driven standard path. Throws on
68
90
  * unknown/unsupported types before any network call.
91
+ *
92
+ * `force` is the caller's acknowledgment of destructive side effects; it rides
93
+ * the delete RPC for kinds whose contract carries a force field (see
94
+ * DELETE_HANDLERS). The command layer maps `-f/--force` onto it.
69
95
  */
70
96
  export async function planDelete(
71
97
  client: Stigmer,
72
98
  typeArg: string,
73
99
  reference: string,
74
100
  org: string,
101
+ force = false,
75
102
  ): Promise<DeletePlan> {
76
103
  if (isExecutionAlias(typeArg)) {
77
104
  return planExecutionCancel(client, reference);
@@ -79,9 +106,7 @@ export async function planDelete(
79
106
 
80
107
  const info = defaultRegistry().getByAlias(typeArg);
81
108
  if (info === undefined) {
82
- throw new UsageError(
83
- `unknown resource type: ${typeArg}\n\nAvailable types: agent, agent-instance, workflow, mcpserver, project, skill, execution, organization`,
84
- );
109
+ throw new UsageError(`unknown resource type: ${typeArg}\n\nAvailable types: ${availableDeleteTypes()}`);
85
110
  }
86
111
 
87
112
  if (info.kind === ApiResourceKind.organization) {
@@ -97,7 +122,7 @@ export async function planDelete(
97
122
  throw new UsageError(`delete not implemented for ${info.displayName}`);
98
123
  }
99
124
 
100
- return planStandardDelete(client, info, reference, org, deleteFn);
125
+ return planStandardDelete(client, info, reference, org, deleteFn, force);
101
126
  }
102
127
 
103
128
  async function planStandardDelete(
@@ -106,6 +131,7 @@ async function planStandardDelete(
106
131
  reference: string,
107
132
  org: string,
108
133
  deleteFn: DeleteFn,
134
+ force: boolean,
109
135
  ): Promise<DeletePlan> {
110
136
  // Safe by construction: every DELETE_HANDLERS key also has a get binding.
111
137
  const getter = getterFor(info.kind);
@@ -122,7 +148,7 @@ async function planStandardDelete(
122
148
  return {
123
149
  warning: buildDeleteWarning(info, resource),
124
150
  confirmPrompt: "Proceed with deletion? [y/N]",
125
- perform: async () => buildDeleteSuccess(info, await deleteFn(client, id)),
151
+ perform: async () => buildDeleteSuccess(info, await deleteFn(client, id, force)),
126
152
  };
127
153
  }
128
154
 
@@ -200,6 +226,19 @@ function buildDeleteWarning(info: TypeInfo, message: HasMetadata): CommandResult
200
226
  warning.hint("This will delete the skill and all its versions.");
201
227
  }
202
228
 
229
+ if (info.kind === ApiResourceKind.datastore) {
230
+ // Mirrors the server's non-empty guard (GuardNonEmptyStep): the delete of
231
+ // a datastore holding records is refused unless force acknowledges it.
232
+ warning.hint("All records held by this datastore will be destroyed.");
233
+ warning.hint("A datastore holding records is refused unless --force acknowledges destroying them.");
234
+ }
235
+
236
+ if (info.kind === ApiResourceKind.agent_channel) {
237
+ // The server's documented distinction: delete is the connection's full
238
+ // teardown; disabling is the config-preserving pause.
239
+ warning.hint("Delete is the connection's full teardown; to pause instead, apply with spec.enabled: false.");
240
+ }
241
+
203
242
  warning.hint("This action cannot be undone.");
204
243
  return warning;
205
244
  }
@@ -5,7 +5,11 @@
5
5
 
6
6
  import type { DescMessage, Message } from "@bufbuild/protobuf";
7
7
  import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
8
+ import { AgentChannelSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
8
9
  import { AgentInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
10
+ import { ChannelAppSchema } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/api_pb";
11
+ import { DatastoreSchema } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/api_pb";
12
+ import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
9
13
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
10
14
  import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
11
15
  import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
@@ -29,6 +33,10 @@ const GET_BINDINGS: ReadonlyMap<ApiResourceKind, Getter> = new Map([
29
33
  [ApiResourceKind.workflow, refGetter(ApiResourceKind.workflow, WorkflowSchema, (c) => c.workflow)],
30
34
  [ApiResourceKind.mcp_server, refGetter(ApiResourceKind.mcp_server, McpServerSchema, (c) => c.mcpServer)],
31
35
  [ApiResourceKind.project, refGetter(ApiResourceKind.project, ProjectSchema, (c) => c.project)],
36
+ [ApiResourceKind.datastore, refGetter(ApiResourceKind.datastore, DatastoreSchema, (c) => c.datastore)],
37
+ [ApiResourceKind.environment, refGetter(ApiResourceKind.environment, EnvironmentSchema, (c) => c.environment)],
38
+ [ApiResourceKind.agent_channel, refGetter(ApiResourceKind.agent_channel, AgentChannelSchema, (c) => c.agentChannel)],
39
+ [ApiResourceKind.channel_app, refGetter(ApiResourceKind.channel_app, ChannelAppSchema, (c) => c.channelapp)],
32
40
  [ApiResourceKind.skill, refGetter(ApiResourceKind.skill, SkillSchema, (c) => c.skill)],
33
41
  [ApiResourceKind.api_key, idOnlyGetter(ApiKeySchema, (c) => c.apiKey, "API keys")],
34
42
  ]);
@@ -25,6 +25,7 @@ const SEARCH_KINDS = new Set<ApiResourceKind>([
25
25
  ApiResourceKind.mcp_server,
26
26
  ApiResourceKind.project,
27
27
  ApiResourceKind.skill,
28
+ ApiResourceKind.datastore,
28
29
  ]);
29
30
 
30
31
  export async function listResources(
@@ -16,9 +16,21 @@ import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentex
16
16
  import { AgentExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/command_pb";
17
17
  import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
18
18
  import { AgentExecutionQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/query_pb";
19
+ import { AgentChannelSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
20
+ import { AgentChannelCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/command_pb";
21
+ import { AgentChannelQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/query_pb";
19
22
  import { AgentInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
20
23
  import { AgentInstanceCommandController } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/command_pb";
21
24
  import { AgentInstanceQueryController } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/query_pb";
25
+ import { ChannelAppSchema } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/api_pb";
26
+ import { ChannelAppCommandController } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/command_pb";
27
+ import { ChannelAppQueryController } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/query_pb";
28
+ import { DatastoreSchema } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/api_pb";
29
+ import { DatastoreCommandController } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/command_pb";
30
+ import { DatastoreQueryController } from "@stigmer/protos/ai/stigmer/agentic/datastore/v1/query_pb";
31
+ import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
32
+ import { EnvironmentCommandController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/command_pb";
33
+ import { EnvironmentQueryController } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/query_pb";
22
34
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
23
35
  import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
24
36
  import { McpServerQueryController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/query_pb";
@@ -51,6 +63,28 @@ const knownWorkflow = create(WorkflowSchema, {
51
63
  metadata: { name: "Deploy", slug: "deploy", org: "acme", id: "wfl_1" },
52
64
  });
53
65
 
66
+ // The mock treats this datastore as NON-EMPTY: delete refuses without the
67
+ // force acknowledgment, mirroring the server's GuardNonEmptyStep contract.
68
+ const knownDatastore = create(DatastoreSchema, {
69
+ metadata: { name: "clinic-records", slug: "clinic-records", org: "acme", id: "dst_1" },
70
+ });
71
+
72
+ // Byte-shaped like the Go server's GuardNonEmptyStep refusal (delete_guards.go).
73
+ const nonEmptyGuardMessage =
74
+ 'datastore "clinic-records" holds 12 records across 3 collections; deleting destroys them — pass force to acknowledge';
75
+
76
+ const knownEnvironment = create(EnvironmentSchema, {
77
+ metadata: { name: "clinic-patient-db", slug: "clinic-patient-db", org: "acme", id: "env_1" },
78
+ });
79
+
80
+ const knownChannel = create(AgentChannelSchema, {
81
+ metadata: { name: "clinic-patient-whatsapp", slug: "clinic-patient-whatsapp", org: "acme", id: "ach_1" },
82
+ });
83
+
84
+ const knownChannelApp = create(ChannelAppSchema, {
85
+ metadata: { name: "clinic-meta-app", slug: "clinic-meta-app", org: "acme", id: "chapp_1" },
86
+ });
87
+
54
88
  // Pending execution (cancellable) vs. an already-terminal one.
55
89
  const pendingExecution = create(AgentExecutionSchema, {
56
90
  metadata: { id: "aex_run" },
@@ -72,10 +106,20 @@ const openSessions = new Set<ServerHttp2Session>();
72
106
  // Spies for the command-side calls, reset per test.
73
107
  let cancelCalls: string[] = [];
74
108
  let tagCalls: { workflowId: string; versionHash: string; tag: string }[] = [];
109
+ // Force-carrying deletes record what actually rode the RPC.
110
+ let datastoreDeletes: { resourceId: string; force: boolean }[] = [];
111
+ let environmentDeletes: { resourceId: string; force: boolean }[] = [];
112
+ let channelAppDeletes: { resourceId: string; force: boolean }[] = [];
113
+ // agent_channel's delete is a typed ID — no force field exists on the wire.
114
+ let channelDeleteIds: string[] = [];
75
115
 
76
116
  beforeEach(() => {
77
117
  cancelCalls = [];
78
118
  tagCalls = [];
119
+ datastoreDeletes = [];
120
+ environmentDeletes = [];
121
+ channelAppDeletes = [];
122
+ channelDeleteIds = [];
79
123
  });
80
124
 
81
125
  beforeAll(async () => {
@@ -118,6 +162,60 @@ beforeAll(async () => {
118
162
  },
119
163
  });
120
164
 
165
+ router.service(DatastoreQueryController, {
166
+ getByReference: (req) => {
167
+ if (req.slug !== "clinic-records") throw new ConnectError("datastore not found", Code.NotFound);
168
+ return knownDatastore;
169
+ },
170
+ });
171
+ router.service(DatastoreCommandController, {
172
+ delete: (req) => {
173
+ datastoreDeletes.push({ resourceId: req.resourceId, force: req.force });
174
+ // Mirrors GuardNonEmptyStep: a non-empty datastore refuses without force.
175
+ if (!req.force) throw new ConnectError(nonEmptyGuardMessage, Code.FailedPrecondition);
176
+ return knownDatastore;
177
+ },
178
+ });
179
+
180
+ router.service(EnvironmentQueryController, {
181
+ getByReference: (req) => {
182
+ if (req.slug !== "clinic-patient-db") throw new ConnectError("environment not found", Code.NotFound);
183
+ return knownEnvironment;
184
+ },
185
+ });
186
+ router.service(EnvironmentCommandController, {
187
+ delete: (req) => {
188
+ environmentDeletes.push({ resourceId: req.resourceId, force: req.force });
189
+ return knownEnvironment;
190
+ },
191
+ });
192
+
193
+ router.service(AgentChannelQueryController, {
194
+ getByReference: (req) => {
195
+ if (req.slug !== "clinic-patient-whatsapp") throw new ConnectError("agent channel not found", Code.NotFound);
196
+ return knownChannel;
197
+ },
198
+ });
199
+ router.service(AgentChannelCommandController, {
200
+ delete: (req) => {
201
+ channelDeleteIds.push(req.value);
202
+ return knownChannel;
203
+ },
204
+ });
205
+
206
+ router.service(ChannelAppQueryController, {
207
+ getByReference: (req) => {
208
+ if (req.slug !== "clinic-meta-app") throw new ConnectError("channel app not found", Code.NotFound);
209
+ return knownChannelApp;
210
+ },
211
+ });
212
+ router.service(ChannelAppCommandController, {
213
+ delete: (req) => {
214
+ channelAppDeletes.push({ resourceId: req.resourceId, force: req.force });
215
+ return knownChannelApp;
216
+ },
217
+ });
218
+
121
219
  router.service(WorkflowQueryController, {
122
220
  getByReference: (req) => {
123
221
  if (req.slug !== "deploy") throw new ConnectError("workflow not found", Code.NotFound);
@@ -208,9 +306,17 @@ describe("delete (standard kinds)", () => {
208
306
  expect(classify(err)?.exitCode).toBe(ExitCode.NotFound);
209
307
  });
210
308
 
211
- it("rejects an unknown type with a usage error", async () => {
309
+ it("rejects an unknown type and lists exactly the wired types", async () => {
212
310
  const err = await planDelete(client, "bogus", "x", "acme").catch((e) => e);
213
311
  expect(classify(err)?.exitCode).toBe(ExitCode.Usage);
312
+ // 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.
314
+ const message = String(err.message);
315
+ for (const wired of ["datastore", "environment", "agentchannel", "channelapp", "execution", "organization"]) {
316
+ expect(message).toContain(wired);
317
+ }
318
+ expect(message).not.toContain("session");
319
+ expect(message).not.toContain("oauthapp");
214
320
  });
215
321
 
216
322
  it("rejects a kind that has no delete handler with a usage error", async () => {
@@ -219,6 +325,77 @@ describe("delete (standard kinds)", () => {
219
325
  });
220
326
  });
221
327
 
328
+ describe("delete (force acknowledgment)", () => {
329
+ it("threads --force to the datastore delete RPC as the destroy-records ack", async () => {
330
+ const plan = await planDelete(client, "datastore", "clinic-records", "acme", true);
331
+ const result = await plan.perform();
332
+
333
+ expect(result.status).toBe("success");
334
+ expect(result.message).toBe("Datastore deleted successfully");
335
+ expect(datastoreDeletes).toEqual([{ resourceId: "dst_1", force: true }]);
336
+ });
337
+
338
+ it("surfaces the non-empty guard refusal verbatim when force is absent", async () => {
339
+ const plan = await planDelete(client, "datastore", "clinic-records", "acme");
340
+ const err = await plan.perform().catch((e) => e);
341
+
342
+ // A confirmed-but-unforced delete must NOT bypass the server guard: the
343
+ // RPC carries force=false and the refusal reaches the user verbatim.
344
+ expect(datastoreDeletes).toEqual([{ resourceId: "dst_1", force: false }]);
345
+ const classified = classify(err);
346
+ expect(classified?.exitCode).toBe(ExitCode.Usage);
347
+ expect(classified?.message).toBe(`Precondition failed: ${nonEmptyGuardMessage}`);
348
+ });
349
+
350
+ it("warns up front that datastore records are destroyed and force-guarded", async () => {
351
+ const plan = await planDelete(client, "datastore", "clinic-records", "acme");
352
+ expect(plan.warning.hints).toContain("All records held by this datastore will be destroyed.");
353
+ expect(plan.warning.hints).toContain(
354
+ "A datastore holding records is refused unless --force acknowledges destroying them.",
355
+ );
356
+ });
357
+ });
358
+
359
+ describe("delete (cutover kinds: environment, agent channel, channel app)", () => {
360
+ it("deletes an environment via the DeleteResourceInput shape", async () => {
361
+ const plan = await planDelete(client, "environment", "clinic-patient-db", "acme");
362
+ const result = await plan.perform();
363
+
364
+ expect(result.status).toBe("success");
365
+ expect(result.message).toBe("Environment deleted successfully");
366
+ expect(environmentDeletes).toEqual([{ resourceId: "env_1", force: false }]);
367
+ });
368
+
369
+ it("threads --force through the environment delete (generic ack carrier)", async () => {
370
+ const plan = await planDelete(client, "environment", "clinic-patient-db", "acme", true);
371
+ await plan.perform();
372
+ expect(environmentDeletes).toEqual([{ resourceId: "env_1", force: true }]);
373
+ });
374
+
375
+ it("deletes an agent channel via its typed-ID contract, ignoring force gracefully", async () => {
376
+ const plan = await planDelete(client, "agent-channel", "clinic-patient-whatsapp", "acme", true);
377
+
378
+ // The teardown-vs-pause distinction rides the warning (server doc contract).
379
+ expect(plan.warning.hints).toContain(
380
+ "Delete is the connection's full teardown; to pause instead, apply with spec.enabled: false.",
381
+ );
382
+
383
+ const result = await plan.perform();
384
+ expect(result.status).toBe("success");
385
+ expect(result.message).toBe("Agent Channel deleted successfully");
386
+ expect(channelDeleteIds).toEqual(["ach_1"]);
387
+ });
388
+
389
+ it("deletes a channel app via the DeleteResourceInput shape", async () => {
390
+ const plan = await planDelete(client, "channel-app", "clinic-meta-app", "acme");
391
+ const result = await plan.perform();
392
+
393
+ expect(result.status).toBe("success");
394
+ expect(result.message).toBe("Channel App deleted successfully");
395
+ expect(channelAppDeletes).toEqual([{ resourceId: "chapp_1", force: false }]);
396
+ });
397
+ });
398
+
222
399
  describe("delete execution (cancel special case)", () => {
223
400
  it("cancels a non-terminal execution", async () => {
224
401
  const plan = await planDelete(client, "execution", "aex_run", "");