@nebutra/agent-runtime 0.2.1 → 2.0.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 (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
@@ -1,176 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { z } from "zod";
3
- import { isTurnTerminal, mergeTurnConfig, type TurnConfig } from "./model";
4
- import { DEFAULT_CAPABILITY_POLICY, DENIED, isApproval, resolveRuleDecision } from "./policy";
5
- import { METHOD_REGISTRY, resolveScope, scopeKey } from "./protocol";
6
- import {
7
- InMemoryRolloutStore,
8
- PERSISTED_OUTPUT_MAX_BYTES,
9
- replay,
10
- sanitizeForPersist,
11
- } from "./rollout";
12
- import {
13
- assertSafePosture,
14
- createHttpSandbox,
15
- NoExecutorConfiguredError,
16
- REFUSING_SANDBOX,
17
- SandboxDelegationError,
18
- } from "./sandbox";
19
- import { ToolRegistry } from "./tools";
20
-
21
- const baseConfig: TurnConfig = {
22
- model: "m",
23
- provider: "p",
24
- approvalPolicy: "on_request",
25
- capabilityPolicy: "external_sandbox",
26
- };
27
-
28
- describe("model", () => {
29
- it("merges overrides immutably", () => {
30
- const merged = mergeTurnConfig(baseConfig, { model: "x" });
31
- expect(merged.model).toBe("x");
32
- expect(baseConfig.model).toBe("m");
33
- });
34
- it("detects terminal turn events", () => {
35
- expect(
36
- isTurnTerminal({
37
- type: "turn.completed",
38
- usage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0 },
39
- }),
40
- ).toBe(true);
41
- expect(isTurnTerminal({ type: "turn.started" })).toBe(false);
42
- });
43
- });
44
-
45
- describe("policy", () => {
46
- it("defaults to external_sandbox posture", () => {
47
- expect(DEFAULT_CAPABILITY_POLICY.kind).toBe("external_sandbox");
48
- });
49
- it("forbidden rule auto-rejects regardless of policy", () => {
50
- expect(resolveRuleDecision("forbidden", { kind: "on_request" })).toBe("auto_reject");
51
- });
52
- it("prompt rule under never policy auto-rejects", () => {
53
- expect(resolveRuleDecision("prompt", { kind: "never" })).toBe("auto_reject");
54
- });
55
- it("prompt rule under granular respects the rules gate", () => {
56
- expect(
57
- resolveRuleDecision("prompt", {
58
- kind: "granular",
59
- config: {
60
- sandboxApproval: true,
61
- rules: false,
62
- skillApproval: false,
63
- requestPermissions: false,
64
- mcpElicitations: true,
65
- },
66
- }),
67
- ).toBe("auto_reject");
68
- });
69
- it("denied is not an approval", () => {
70
- expect(isApproval(DENIED)).toBe(false);
71
- expect(isApproval({ kind: "approved_for_session" })).toBe(true);
72
- });
73
- });
74
-
75
- describe("protocol", () => {
76
- it("scope key is tenant-prefixed and thread-isolated", () => {
77
- const a = resolveScope(METHOD_REGISTRY.turnStart, "t1", { threadId: "th1" });
78
- const b = resolveScope(METHOD_REGISTRY.turnStart, "t2", { threadId: "th1" });
79
- expect(scopeKey(a)).not.toBe(scopeKey(b)); // cross-tenant never shares a lane
80
- expect(scopeKey(a)).toContain("t:t1");
81
- });
82
- it("thread-scoped method without threadId fails closed", () => {
83
- expect(() => resolveScope(METHOD_REGISTRY.turnStart, "t1", {})).toThrow();
84
- });
85
- });
86
-
87
- describe("rollout", () => {
88
- it("caps oversized command output", () => {
89
- const big = "x".repeat(PERSISTED_OUTPUT_MAX_BYTES + 100);
90
- const out = sanitizeForPersist({
91
- id: "1",
92
- type: "command_execution",
93
- command: "c",
94
- aggregatedOutput: big,
95
- status: "completed",
96
- });
97
- expect((out as { aggregatedOutput: string }).aggregatedOutput.length).toBeLessThan(big.length);
98
- });
99
- it("replays an append-only log into derived state, bounded by compaction", async () => {
100
- const store = new InMemoryRolloutStore();
101
- const at = new Date().toISOString();
102
- await store.append({
103
- tenantId: "t",
104
- threadId: "th",
105
- type: "session_meta",
106
- config: baseConfig,
107
- at,
108
- });
109
- await store.append({
110
- tenantId: "t",
111
- threadId: "th",
112
- type: "event",
113
- at,
114
- event: { type: "item.completed", item: { id: "i1", type: "agent_message", text: "hi" } },
115
- });
116
- await store.append({
117
- tenantId: "t",
118
- threadId: "th",
119
- type: "compacted",
120
- summary: "summary",
121
- droppedThrough: "i1",
122
- at,
123
- });
124
- const proj = replay(await store.read("t", "th"));
125
- expect(proj?.compactionSummary).toBe("summary");
126
- expect(proj?.items).toHaveLength(0);
127
- });
128
- });
129
-
130
- describe("sandbox", () => {
131
- it("refuses execution by default (no in-process untrusted exec)", async () => {
132
- await expect(
133
- REFUSING_SANDBOX.exec({
134
- tenantId: "t",
135
- threadId: "th",
136
- command: "rm -rf /",
137
- capabilityPolicy: DEFAULT_CAPABILITY_POLICY,
138
- }),
139
- ).rejects.toBeInstanceOf(NoExecutorConfiguredError);
140
- });
141
- it("refuses danger_full_access without explicit opt-in", () => {
142
- expect(() => assertSafePosture({ kind: "danger_full_access" })).toThrow();
143
- expect(() => assertSafePosture({ kind: "danger_full_access" }, true)).not.toThrow();
144
- });
145
- it("surfaces a fail-closed isolator refusal as an error, never a fake result", async () => {
146
- const fakeFetch = (async () =>
147
- new Response(JSON.stringify({ error: "execution_refused" }), {
148
- status: 403,
149
- })) as unknown as typeof fetch;
150
- const sandbox = createHttpSandbox("http://isolator:8020", fakeFetch);
151
- await expect(
152
- sandbox.exec({
153
- tenantId: "org_a",
154
- threadId: "th_1",
155
- command: "rm -rf /",
156
- capabilityPolicy: DEFAULT_CAPABILITY_POLICY,
157
- }),
158
- ).rejects.toBeInstanceOf(SandboxDelegationError);
159
- });
160
- });
161
-
162
- describe("tools", () => {
163
- it("validates input and dispatches with tenant context", async () => {
164
- const reg = new ToolRegistry();
165
- reg.register(
166
- { name: "echo", description: "echo", inputSchema: z.object({ v: z.string() }) },
167
- async (input: { v: string }, ctx) => `${ctx.tenantId}:${input.v}`,
168
- );
169
- expect(await reg.dispatch("echo", { v: "hi" }, { tenantId: "t1", threadId: "th" })).toBe(
170
- "t1:hi",
171
- );
172
- await expect(
173
- reg.dispatch("echo", { v: 1 }, { tenantId: "t1", threadId: "th" }),
174
- ).rejects.toBeTruthy();
175
- });
176
- });
@@ -1,330 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import {
3
- type ActionPorts,
4
- ActionRunner,
5
- ArtifactStreamParser,
6
- type PlanAction,
7
- stripFencedWrapper,
8
- unescapeEntities,
9
- } from "./artifact-stream";
10
-
11
- // ── Pure helpers ─────────────────────────────────────────────────────────────
12
-
13
- describe("stripFencedWrapper", () => {
14
- it("strips a single ```lang fenced wrapper", () => {
15
- expect(stripFencedWrapper("```ts\nconst a = 1;\n```")).toBe("const a = 1;");
16
- });
17
-
18
- it("strips a bare ``` fence", () => {
19
- expect(stripFencedWrapper("```\nhello\n```")).toBe("hello");
20
- });
21
-
22
- it("leaves unfenced content untouched", () => {
23
- expect(stripFencedWrapper("plain text")).toBe("plain text");
24
- });
25
-
26
- it("does not strip inner fences (single wrapper only)", () => {
27
- const input = "```md\nuse ```code``` here\n```";
28
- expect(stripFencedWrapper(input)).toBe("use ```code``` here");
29
- });
30
- });
31
-
32
- describe("unescapeEntities", () => {
33
- it("unescapes < and >", () => {
34
- expect(unescapeEntities("a &lt;tag&gt; b")).toBe("a <tag> b");
35
- });
36
-
37
- it("is a no-op without entities", () => {
38
- expect(unescapeEntities("nothing here")).toBe("nothing here");
39
- });
40
- });
41
-
42
- // ── Parser ───────────────────────────────────────────────────────────────────
43
-
44
- function collector() {
45
- const events: string[] = [];
46
- return {
47
- events,
48
- onArtifactOpen: vi.fn((e) => events.push(`open:${e.artifactId}:${e.title}`)),
49
- onArtifactClose: vi.fn((e) => events.push(`close:${e.artifactId}`)),
50
- onActionOpen: vi.fn((e) => events.push(`aopen:${e.actionId}:${e.action.type}`)),
51
- onActionStream: vi.fn((e) => events.push(`astream:${e.actionId}`)),
52
- onActionClose: vi.fn((e) => events.push(`aclose:${e.actionId}:${e.action.type}`)),
53
- };
54
- }
55
-
56
- const CTX = { tenantId: "org_123" };
57
-
58
- describe("ArtifactStreamParser", () => {
59
- it("parses a single-action artifact in one chunk", () => {
60
- const c = collector();
61
- const p = new ArtifactStreamParser(c);
62
- p.parse(
63
- "m1",
64
- CTX,
65
- `<artifact id="a1" title="Build">` +
66
- `<action type="file" filePath="src/x.ts">const x = 1;</action></artifact>`,
67
- );
68
-
69
- expect(c.onArtifactOpen).toHaveBeenCalledWith(
70
- expect.objectContaining({
71
- tenantId: "org_123",
72
- messageId: "m1",
73
- artifactId: "a1",
74
- title: "Build",
75
- }),
76
- );
77
- expect(c.onActionClose).toHaveBeenCalledWith(
78
- expect.objectContaining({
79
- tenantId: "org_123",
80
- action: { type: "file", filePath: "src/x.ts", content: "const x = 1;" },
81
- }),
82
- );
83
- expect(c.onArtifactClose).toHaveBeenCalledTimes(1);
84
- });
85
-
86
- it("handles a tag split across two chunks without dup emit", () => {
87
- const c = collector();
88
- const p = new ArtifactStreamParser(c);
89
- p.parse("m1", CTX, `<artifact id="a1" title="Sp`);
90
- p.parse("m1", CTX, `lit"><action type="shell">npm i</acti`);
91
- p.parse("m1", CTX, `on></artifact>`);
92
-
93
- expect(c.onArtifactOpen).toHaveBeenCalledTimes(1);
94
- expect(c.onArtifactOpen).toHaveBeenCalledWith(
95
- expect.objectContaining({ artifactId: "a1", title: "Split" }),
96
- );
97
- expect(c.onActionClose).toHaveBeenCalledTimes(1);
98
- expect(c.onActionClose).toHaveBeenCalledWith(
99
- expect.objectContaining({ action: { type: "shell", content: "npm i" } }),
100
- );
101
- expect(c.onArtifactClose).toHaveBeenCalledTimes(1);
102
- });
103
-
104
- it("streams partial content via onActionStream then final via onActionClose", () => {
105
- const c = collector();
106
- const p = new ArtifactStreamParser(c);
107
- p.parse("m1", CTX, `<artifact id="a1" title="T"><action type="file" filePath="f">par`);
108
- p.parse("m1", CTX, `tial</action></artifact>`);
109
-
110
- expect(c.onActionStream).toHaveBeenCalled();
111
- expect(c.onActionClose).toHaveBeenCalledWith(
112
- expect.objectContaining({ action: { type: "file", filePath: "f", content: "partial" } }),
113
- );
114
- });
115
-
116
- it("strips a fenced wrapper and unescapes entities in action content", () => {
117
- const c = collector();
118
- const p = new ArtifactStreamParser(c);
119
- p.parse(
120
- "m1",
121
- CTX,
122
- '<artifact id="a1" title="T"><action type="file" filePath="f">' +
123
- "```ts\nconst t = &lt;X&gt;();\n```" +
124
- "</action></artifact>",
125
- );
126
-
127
- expect(c.onActionClose).toHaveBeenCalledWith(
128
- expect.objectContaining({
129
- action: { type: "file", filePath: "f", content: "const t = <X>();" },
130
- }),
131
- );
132
- });
133
-
134
- it("preserves order of multiple actions in one artifact", () => {
135
- const c = collector();
136
- const p = new ArtifactStreamParser(c);
137
- p.parse(
138
- "m1",
139
- CTX,
140
- `<artifact id="a1" title="Multi">` +
141
- `<action type="file" filePath="a.ts">A</action>` +
142
- `<action type="shell">npm test</action>` +
143
- `<action type="start">npm run dev</action>` +
144
- `</artifact>`,
145
- );
146
-
147
- const opens = c.events.filter((e) => e.startsWith("aclose:"));
148
- expect(opens).toEqual(["aclose:0:file", "aclose:1:shell", "aclose:2:start"]);
149
- });
150
-
151
- it("parses a data action with operation + projectId", () => {
152
- const c = collector();
153
- const p = new ArtifactStreamParser(c);
154
- p.parse(
155
- "m1",
156
- CTX,
157
- `<artifact id="a1" title="T"><action type="data" operation="migration" projectId="p1">SQL</action></artifact>`,
158
- );
159
-
160
- expect(c.onActionClose).toHaveBeenCalledWith(
161
- expect.objectContaining({
162
- action: { type: "data", operation: "migration", projectId: "p1", content: "SQL" },
163
- }),
164
- );
165
- });
166
-
167
- it("isolates state per messageId", () => {
168
- const c = collector();
169
- const p = new ArtifactStreamParser(c);
170
- p.parse("m1", CTX, `<artifact id="a1" title="One">`);
171
- p.parse("m2", CTX, `<artifact id="b1" title="Two">`);
172
- p.parse("m1", CTX, `<action type="shell">x</action></artifact>`);
173
- p.parse("m2", CTX, `<action type="shell">y</action></artifact>`);
174
-
175
- expect(c.onArtifactOpen).toHaveBeenCalledWith(expect.objectContaining({ artifactId: "a1" }));
176
- expect(c.onArtifactOpen).toHaveBeenCalledWith(expect.objectContaining({ artifactId: "b1" }));
177
- expect(c.onActionClose).toHaveBeenCalledTimes(2);
178
- });
179
-
180
- it("fails closed on empty tenantId", () => {
181
- const c = collector();
182
- const p = new ArtifactStreamParser(c);
183
- expect(() => p.parse("m1", { tenantId: "" }, '<artifact id="a" title="T">')).toThrow();
184
- });
185
- });
186
-
187
- // ── Runner ───────────────────────────────────────────────────────────────────
188
-
189
- function ports() {
190
- const p = {
191
- writeFile: vi.fn(async (_a: PlanAction, _c: { tenantId: string }) => undefined),
192
- runShell: vi.fn(async (_a: PlanAction, _c: { tenantId: string }) => ({
193
- exitCode: 0,
194
- output: "ok",
195
- })),
196
- dataOp: vi.fn(async (_a: PlanAction, _c: { tenantId: string }) => undefined),
197
- };
198
- return p as typeof p & ActionPorts;
199
- }
200
-
201
- const fileAction: PlanAction = { type: "file", filePath: "a.ts", content: "x" };
202
- const shellAction: PlanAction = { type: "shell", content: "npm i" };
203
-
204
- describe("ActionRunner — state machine", () => {
205
- it("transitions pending -> running -> complete", async () => {
206
- const r = new ActionRunner(ports(), CTX);
207
- r.register("0", fileAction);
208
- expect(r.get("0").status).toBe("pending");
209
- await r.runAction("0");
210
- expect(r.get("0").status).toBe("complete");
211
- expect(r.get("0").executed).toBe(true);
212
- });
213
-
214
- it("delegates file write to injected port (no in-proc exec)", async () => {
215
- const p = ports();
216
- const r = new ActionRunner(p, CTX);
217
- r.register("0", fileAction);
218
- await r.runAction("0");
219
- expect(p.writeFile).toHaveBeenCalledWith(
220
- fileAction,
221
- expect.objectContaining({ tenantId: "org_123" }),
222
- );
223
- });
224
-
225
- it("delegates shell to injected port", async () => {
226
- const p = ports();
227
- const r = new ActionRunner(p, CTX);
228
- r.register("0", shellAction);
229
- await r.runAction("0");
230
- expect(p.runShell).toHaveBeenCalledWith(
231
- shellAction,
232
- expect.objectContaining({ tenantId: "org_123" }),
233
- );
234
- });
235
-
236
- it("delegates data action to dataOp port", async () => {
237
- const p = ports();
238
- const r = new ActionRunner(p, CTX);
239
- const dataAction: PlanAction = { type: "data", operation: "query", content: "SELECT 1" };
240
- r.register("0", dataAction);
241
- await r.runAction("0");
242
- expect(p.dataOp).toHaveBeenCalledWith(
243
- dataAction,
244
- expect.objectContaining({ tenantId: "org_123" }),
245
- );
246
- });
247
-
248
- it("transitions to failed when a port throws", async () => {
249
- const p = ports();
250
- p.runShell.mockRejectedValueOnce(new Error("boom"));
251
- const r = new ActionRunner(p, CTX);
252
- r.register("0", shellAction);
253
- await r.runAction("0");
254
- expect(r.get("0").status).toBe("failed");
255
- expect(r.get("0").error).toContain("boom");
256
- });
257
-
258
- it("throws on illegal transition (run an already complete action)", async () => {
259
- const r = new ActionRunner(ports(), CTX);
260
- r.register("0", fileAction);
261
- await r.runAction("0");
262
- await expect(r.runAction("0")).rejects.toThrow();
263
- });
264
-
265
- it("abort allowed from pending", () => {
266
- const r = new ActionRunner(ports(), CTX);
267
- r.register("0", fileAction);
268
- r.abort("0");
269
- expect(r.get("0").status).toBe("aborted");
270
- });
271
-
272
- it("abort rejected from complete", async () => {
273
- const r = new ActionRunner(ports(), CTX);
274
- r.register("0", fileAction);
275
- await r.runAction("0");
276
- expect(() => r.abort("0")).toThrow();
277
- });
278
- });
279
-
280
- describe("ActionRunner — queue", () => {
281
- it("runs actions in submission order", async () => {
282
- const p = ports();
283
- const order: string[] = [];
284
- p.writeFile.mockImplementation(async (a: PlanAction) => {
285
- order.push((a as { filePath: string }).filePath);
286
- });
287
- const r = new ActionRunner(p, CTX);
288
- r.register("0", { type: "file", filePath: "first", content: "" });
289
- r.register("1", { type: "file", filePath: "second", content: "" });
290
- await r.runQueue();
291
- expect(order).toEqual(["first", "second"]);
292
- });
293
-
294
- it("a failed required action halts the queue", async () => {
295
- const p = ports();
296
- p.runShell.mockRejectedValueOnce(new Error("fail"));
297
- const r = new ActionRunner(p, CTX);
298
- r.register("0", shellAction);
299
- r.register("1", fileAction);
300
- await r.runQueue();
301
- expect(r.get("0").status).toBe("failed");
302
- expect(r.get("1").status).toBe("pending");
303
- expect(p.writeFile).not.toHaveBeenCalled();
304
- });
305
-
306
- it("continueOnError keeps draining the queue", async () => {
307
- const p = ports();
308
- p.runShell.mockRejectedValueOnce(new Error("fail"));
309
- const r = new ActionRunner(p, CTX);
310
- r.register("0", shellAction);
311
- r.register("1", fileAction);
312
- await r.runQueue({ continueOnError: true });
313
- expect(r.get("0").status).toBe("failed");
314
- expect(r.get("1").status).toBe("complete");
315
- });
316
- });
317
-
318
- describe("ActionRunner — tenant isolation", () => {
319
- it("fails closed on empty tenantId", () => {
320
- expect(() => new ActionRunner(ports(), { tenantId: "" })).toThrow();
321
- });
322
-
323
- it("cross-tenant action map access is impossible", () => {
324
- const a = new ActionRunner(ports(), { tenantId: "org_a" });
325
- a.register("0", fileAction);
326
- const b = new ActionRunner(ports(), { tenantId: "org_b" });
327
- expect(() => b.get("0")).toThrow();
328
- expect(a.get("0").status).toBe("pending");
329
- });
330
- });