@stigmer/runner 3.40.1 → 3.41.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.
- package/README.md +3 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/__test-utils__/execution-record-fixture.d.ts +2 -1
- package/dist/__test-utils__/execution-record-fixture.js.map +1 -1
- package/dist/activities/call-agent.js +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -1
- package/dist/activities/discover-mcp-server.js +13 -29
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/fold.d.ts +4 -2
- package/dist/activities/execute-cursor/__test-utils__/fold.js +3 -4
- package/dist/activities/execute-cursor/__test-utils__/fold.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +1 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
- package/dist/activities/execute-cursor/approval-policy.d.ts +1 -29
- package/dist/activities/execute-cursor/approval-policy.js +11 -35
- package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
- package/dist/activities/execute-cursor/boundary-rows.js +4 -4
- package/dist/activities/execute-cursor/boundary-rows.js.map +1 -1
- package/dist/activities/execute-cursor/translator.d.ts +12 -17
- package/dist/activities/execute-cursor/translator.js +13 -30
- package/dist/activities/execute-cursor/translator.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.js +4 -2
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.d.ts +11 -1
- package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js +22 -3
- package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js.map +1 -1
- package/dist/activities/execute-deep-agent/inline-publisher.d.ts +33 -2
- package/dist/activities/execute-deep-agent/inline-publisher.js +33 -7
- package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
- package/dist/activities/execute-deep-agent/platform-route.d.ts +48 -13
- package/dist/activities/execute-deep-agent/platform-route.js +87 -19
- package/dist/activities/execute-deep-agent/platform-route.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +20 -3
- package/dist/activities/execute-deep-agent/streaming-side-effects.js +26 -4
- package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
- package/dist/activities/execute-deep-agent/translator.d.ts +5 -4
- package/dist/activities/execute-deep-agent/translator.js +5 -4
- package/dist/activities/execute-deep-agent/translator.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn-settle.js +6 -6
- package/dist/activities/execute-deep-agent/turn-settle.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn-setup.js +7 -7
- package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn-stream.d.ts +10 -6
- package/dist/activities/execute-deep-agent/turn-stream.js +6 -16
- package/dist/activities/execute-deep-agent/turn-stream.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn.d.ts +4 -1
- package/dist/activities/execute-deep-agent/turn.js +15 -6
- package/dist/activities/execute-deep-agent/turn.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +2 -0
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/exit-backstop.d.ts +64 -0
- package/dist/exit-backstop.js +79 -0
- package/dist/exit-backstop.js.map +1 -0
- package/dist/harness/terminal-table.d.ts +12 -5
- package/dist/harness/terminal-table.js +13 -13
- package/dist/harness/terminal-table.js.map +1 -1
- package/dist/harness/transcript/builder.d.ts +21 -0
- package/dist/harness/transcript/builder.js +53 -31
- package/dist/harness/transcript/builder.js.map +1 -1
- package/dist/harness/transcript/events.d.ts +22 -26
- package/dist/harness/transcript/events.js +6 -5
- package/dist/harness/transcript/events.js.map +1 -1
- package/dist/harness/turn-context.d.ts +3 -1
- package/dist/harness/turn-context.js +5 -3
- package/dist/harness/turn-context.js.map +1 -1
- package/dist/main.js +10 -4
- package/dist/main.js.map +1 -1
- package/dist/middleware/cost-advisory.d.ts +6 -3
- package/dist/middleware/cost-advisory.js +6 -3
- package/dist/middleware/cost-advisory.js.map +1 -1
- package/dist/middleware/execution-budget.d.ts +22 -7
- package/dist/middleware/execution-budget.js +57 -35
- package/dist/middleware/execution-budget.js.map +1 -1
- package/dist/middleware/index.d.ts +10 -7
- package/dist/middleware/index.js +10 -7
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/types.d.ts +6 -1
- package/dist/runner-manager.js +3 -2
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +25 -0
- package/dist/runner.js +41 -14
- package/dist/runner.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +23 -13
- package/dist/shared/approval-policy.js +47 -14
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/caller-identity.d.ts +4 -5
- package/dist/shared/caller-identity.js +4 -5
- package/dist/shared/caller-identity.js.map +1 -1
- package/dist/shared/channel-attachment.js +3 -2
- package/dist/shared/channel-attachment.js.map +1 -1
- package/dist/shared/connect-backfill.d.ts +2 -1
- package/dist/shared/connect-backfill.js +2 -2
- package/dist/shared/connect-backfill.js.map +1 -1
- package/dist/shared/mcp-resolver.d.ts +10 -1
- package/dist/shared/mcp-resolver.js +11 -2
- package/dist/shared/mcp-resolver.js.map +1 -1
- package/dist/shared/memory-attachment.js +3 -2
- package/dist/shared/memory-attachment.js.map +1 -1
- package/dist/shared/model-error.d.ts +25 -3
- package/dist/shared/model-error.js +78 -10
- package/dist/shared/model-error.js.map +1 -1
- package/dist/shared/platform-server-address.d.ts +71 -0
- package/dist/shared/platform-server-address.js +103 -0
- package/dist/shared/platform-server-address.js.map +1 -0
- package/dist/shared/synthesized-attachment.d.ts +0 -5
- package/dist/shared/synthesized-attachment.js +0 -12
- package/dist/shared/synthesized-attachment.js.map +1 -1
- package/dist/shared/tool-rounds.d.ts +51 -23
- package/dist/shared/tool-rounds.js +67 -24
- package/dist/shared/tool-rounds.js.map +1 -1
- package/dist/shared/workspace/platform-dir.d.ts +15 -0
- package/dist/shared/workspace/platform-dir.js +18 -1
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/dist/shared/workspace/stigmer-link.d.ts +21 -14
- package/dist/shared/workspace/stigmer-link.js +91 -29
- package/dist/shared/workspace/stigmer-link.js.map +1 -1
- package/dist/tools/url-guard.d.ts +5 -1
- package/dist/tools/url-guard.js +9 -2
- package/dist/tools/url-guard.js.map +1 -1
- package/dist/worker.d.ts +11 -2
- package/dist/worker.js +1 -1
- package/dist/worker.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.d.ts +16 -0
- package/dist/workflow-engine/tasks/human-input.js +79 -37
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +14 -4
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +13 -0
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/dist/workflows/engine-core.d.ts +25 -0
- package/dist/workflows/engine-core.js.map +1 -1
- package/dist/workflows/execute-from-execution.d.ts +3 -0
- package/dist/workflows/execute-from-execution.js.map +1 -1
- package/dist/workflows/human-input-orchestrator.d.ts +9 -12
- package/dist/workflows/human-input-orchestrator.js +12 -44
- package/dist/workflows/human-input-orchestrator.js.map +1 -1
- package/package.json +5 -5
- package/src/__test-utils__/config-fixture.ts +1 -0
- package/src/__test-utils__/execution-record-fixture.ts +2 -1
- package/src/__test-utils__/harness-contract/runtime-contract.ts +5 -3
- package/src/__test-utils__/test-gate.ts +8 -0
- package/src/__tests__/exit-backstop.test.ts +91 -0
- package/src/__tests__/golden-e2e.test.ts +80 -62
- package/src/__tests__/history-encryption-e2e.test.ts +51 -27
- package/src/__tests__/runner.test.ts +60 -2
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.hang.test.ts +5 -1
- package/src/activities/__tests__/discover-mcp-server.test.ts +90 -49
- package/src/activities/__tests__/workflow-event-activities.test.ts +28 -7
- package/src/activities/call-agent.ts +1 -1
- package/src/activities/discover-mcp-server.ts +27 -37
- package/src/activities/execute-cursor/__test-utils__/fold.ts +6 -4
- package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +1 -0
- package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +4 -29
- package/src/activities/execute-cursor/__tests__/hermetic/bypassed-tool-call.test.ts +120 -0
- package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +5 -5
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +0 -2
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.error-delta.status.json +0 -3
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.output-first.status.json +0 -2
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.status.json +0 -2
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/stall.status.json +0 -2
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/unattributed-hook-block.status.json +0 -3
- package/src/activities/execute-cursor/__tests__/hermetic/shell-output-delta.test.ts +9 -7
- package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +6 -1
- package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +8 -11
- package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +1 -3
- package/src/activities/execute-cursor/__tests__/translator.test.ts +68 -15
- package/src/activities/execute-cursor/approval-policy.ts +11 -36
- package/src/activities/execute-cursor/boundary-rows.ts +3 -3
- package/src/activities/execute-cursor/translator.ts +15 -32
- package/src/activities/execute-cursor/turn-stream.ts +4 -1
- package/src/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.ts +33 -2
- package/src/activities/execute-deep-agent/__tests__/adapter.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/file-review-capture.test.ts +4 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/file-review-capture.status.json +1 -1
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn1.status.json +1 -1
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn2.status.json +1 -1
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/recursion-limit.persisted.status.json +8 -73
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
- package/src/activities/execute-deep-agent/__tests__/hermetic/inline-artifact-across-gate.test.ts +17 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/recursion-limit.test.ts +86 -26
- package/src/activities/execute-deep-agent/__tests__/hermetic/resolution-error.test.ts +9 -5
- package/src/activities/execute-deep-agent/__tests__/hermetic/thrown-turn-drains-publishes.test.ts +159 -0
- package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +84 -0
- package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +47 -10
- package/src/activities/execute-deep-agent/__tests__/streaming-side-effects.test.ts +34 -0
- package/src/activities/execute-deep-agent/__tests__/turn-stream.test.ts +13 -5
- package/src/activities/execute-deep-agent/inline-publisher.ts +47 -7
- package/src/activities/execute-deep-agent/platform-route.ts +109 -20
- package/src/activities/execute-deep-agent/streaming-side-effects.ts +27 -4
- package/src/activities/execute-deep-agent/translator.ts +5 -4
- package/src/activities/execute-deep-agent/turn-settle.ts +6 -6
- package/src/activities/execute-deep-agent/turn-setup.ts +7 -7
- package/src/activities/execute-deep-agent/turn-stream.ts +15 -22
- package/src/activities/execute-deep-agent/turn.ts +16 -7
- package/src/activities/workflow-event-activities.ts +2 -0
- package/src/config.ts +11 -0
- package/src/exit-backstop.ts +97 -0
- package/src/harness/README.md +5 -5
- package/src/harness/terminal-table.ts +13 -13
- package/src/harness/transcript/__tests__/builder.test.ts +93 -21
- package/src/harness/transcript/builder.ts +53 -30
- package/src/harness/transcript/events.ts +22 -27
- package/src/harness/turn-context.ts +5 -3
- package/src/main.ts +9 -5
- package/src/middleware/__tests__/execution-budget.test.ts +181 -103
- package/src/middleware/cost-advisory.ts +6 -3
- package/src/middleware/execution-budget.ts +60 -34
- package/src/middleware/index.ts +10 -7
- package/src/middleware/types.ts +6 -1
- package/src/runner-manager.ts +3 -2
- package/src/runner.ts +57 -15
- package/src/shared/__tests__/approval-policy.test.ts +41 -0
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/connect-backfill.test.ts +15 -11
- package/src/shared/__tests__/mcp-resolver.test.ts +72 -10
- package/src/shared/__tests__/model-error.test.ts +71 -3
- package/src/shared/__tests__/platform-server-address.test.ts +122 -0
- package/src/shared/__tests__/tool-rounds.test.ts +64 -14
- package/src/shared/approval-policy.ts +57 -14
- package/src/shared/caller-identity.ts +4 -5
- package/src/shared/channel-attachment.ts +2 -2
- package/src/shared/connect-backfill.ts +5 -1
- package/src/shared/mcp-resolver.ts +11 -1
- package/src/shared/memory-attachment.ts +2 -2
- package/src/shared/model-error.ts +87 -9
- package/src/shared/platform-server-address.ts +123 -0
- package/src/shared/synthesized-attachment.ts +0 -12
- package/src/shared/tool-rounds.ts +70 -24
- package/src/shared/workspace/__tests__/stigmer-link.test.ts +107 -10
- package/src/shared/workspace/platform-dir.ts +19 -1
- package/src/shared/workspace/stigmer-link.ts +99 -29
- package/src/tools/__tests__/url-guard.test.ts +19 -1
- package/src/tools/url-guard.ts +9 -1
- package/src/worker.ts +12 -2
- package/src/workflow-engine/__tests__/golden-execution.test.ts +2 -1
- package/src/workflow-engine/__tests__/loader.test.ts +1 -1
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +133 -57
- package/src/workflow-engine/tasks/human-input.ts +92 -42
- package/src/workflow-engine/types.ts +16 -4
- package/src/workflows/call-agent-orchestrator.ts +7 -0
- package/src/workflows/engine-core.ts +18 -0
- package/src/workflows/execute-from-execution.ts +2 -1
- package/src/workflows/human-input-orchestrator.ts +15 -56
package/src/activities/execute-deep-agent/__tests__/hermetic/thrown-turn-drains-publishes.test.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermetic: a THROWN TURN still carries every artifact it published — the
|
|
3
|
+
* native harness drains its in-flight inline publishes before the runtime
|
|
4
|
+
* writes the terminal status (stigmer#1116).
|
|
5
|
+
*
|
|
6
|
+
* The model writes a report in round one (`write_file`), and
|
|
7
|
+
* `streaming-side-effects.ts` hands the path to `InlinePublisher`, whose
|
|
8
|
+
* upload runs beside the stream. Round two's model call fails mid-stream
|
|
9
|
+
* (the scripted model's `fail` ending: a provider error), so the turn never
|
|
10
|
+
* reaches its settle — the one place that used to drain the publishes. The
|
|
11
|
+
* adapter classifies the throw as `failed`, and the runtime writes the
|
|
12
|
+
* turn's terminal status as soon as the adapter returns. The report is an
|
|
13
|
+
* edit on the tree, and the hermetic environment runs in capture mode (it
|
|
14
|
+
* carries artifact storage, as every hosted runner does), so that status is
|
|
15
|
+
* the file review's WAITING_FOR_APPROVAL with the failure's rows riding it
|
|
16
|
+
* and its phase deferred (`terminal-table.ts` `awaitingReviewArm`): the
|
|
17
|
+
* production shape of this fault.
|
|
18
|
+
*
|
|
19
|
+
* The order is made deterministic, not left to I/O timing: the upload is
|
|
20
|
+
* held until the turn asks for its publishes to drain, or until the
|
|
21
|
+
* activity has returned, whichever comes first. With the drain on the
|
|
22
|
+
* turn's every exit, the drain asks first and the artifact is on the
|
|
23
|
+
* persisted status. Without it the activity returns first, the upload
|
|
24
|
+
* lands afterwards on a status nobody persists again, and the assertion on
|
|
25
|
+
* the persisted artifacts fails — the defect this pins.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
31
|
+
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
32
|
+
import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
33
|
+
|
|
34
|
+
vi.mock("../../../../shared/model-client.js", async () =>
|
|
35
|
+
(await import("../../__test-utils__/scripted-model-module.js")).scriptedModelClientModule(),
|
|
36
|
+
);
|
|
37
|
+
vi.mock("../../../../client/stigmer-client.js", async () =>
|
|
38
|
+
(await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
ScriptedClock,
|
|
43
|
+
createHermeticEnvironment,
|
|
44
|
+
type HermeticEnvironment,
|
|
45
|
+
} from "../../../../__test-utils__/hermetic-activity.js";
|
|
46
|
+
import { stubRegistryFetch } from "../../../../__test-utils__/model-registry-fixture.js";
|
|
47
|
+
import {
|
|
48
|
+
FIXTURE,
|
|
49
|
+
beginDeepAgentScenario,
|
|
50
|
+
deepAgentExecutionRecord,
|
|
51
|
+
runDeepAgentTurn,
|
|
52
|
+
} from "../../__test-utils__/hermetic-deep-agent.js";
|
|
53
|
+
import { LocalArtifactStorage } from "../../../../shared/artifact-storage.js";
|
|
54
|
+
import { StreamingSideEffects } from "../../streaming-side-effects.js";
|
|
55
|
+
import { TERMINAL_COPY } from "../../../../harness/terminal-table.js";
|
|
56
|
+
|
|
57
|
+
const REPORT = "report.md";
|
|
58
|
+
const REPORT_BODY = "# Report\n\nWritten before the provider failed.\n";
|
|
59
|
+
const WRITE_CALL_ID = "call-hermetic-write-0001";
|
|
60
|
+
const PROVIDER_FAULT = "provider stream failed mid-turn";
|
|
61
|
+
|
|
62
|
+
function systemMessages(status: AgentExecutionStatus): string[] {
|
|
63
|
+
return status.messages.filter((m) => m.type === MessageType.MESSAGE_SYSTEM).map((m) => m.content);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("ExecuteDeepAgent hermetic — a thrown turn drains its inline publishes", () => {
|
|
67
|
+
let env: HermeticEnvironment;
|
|
68
|
+
let registry: ReturnType<typeof stubRegistryFetch>;
|
|
69
|
+
const clock = new ScriptedClock();
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
env = createHermeticEnvironment();
|
|
73
|
+
registry = stubRegistryFetch();
|
|
74
|
+
clock.install();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(() => {
|
|
82
|
+
clock.uninstall();
|
|
83
|
+
registry.restore();
|
|
84
|
+
env.dispose();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("persists the turn's end with the artifact it published before the model call threw", async () => {
|
|
88
|
+
// ── Arrange ──────────────────────────────────────────────────────────────
|
|
89
|
+
const record = deepAgentExecutionRecord({ message: "Write the report." });
|
|
90
|
+
const scenario = beginDeepAgentScenario({
|
|
91
|
+
env,
|
|
92
|
+
clock,
|
|
93
|
+
record,
|
|
94
|
+
script: () => ({
|
|
95
|
+
turns: [
|
|
96
|
+
{
|
|
97
|
+
text: "Writing the report.",
|
|
98
|
+
toolCalls: [{ id: WRITE_CALL_ID, name: "write_file", args: { file_path: `/${REPORT}`, content: REPORT_BODY } }],
|
|
99
|
+
usage: { inputTokens: 1_300, outputTokens: 60 },
|
|
100
|
+
},
|
|
101
|
+
{ text: "Now summarising", ends: { kind: "fail", error: new Error(PROVIDER_FAULT) } },
|
|
102
|
+
],
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// The artifact's upload waits on `held`; the first of "the turn drains"
|
|
107
|
+
// and "the activity returned" releases it (the header's deterministic
|
|
108
|
+
// order). Capture mode's own blobs share the store and are never held:
|
|
109
|
+
// the runtime writes them after the adapter returns.
|
|
110
|
+
const artifactKey = `artifacts/${FIXTURE.executionId}/${REPORT}`;
|
|
111
|
+
let release = (): void => undefined;
|
|
112
|
+
const held = new Promise<void>((resolve) => {
|
|
113
|
+
release = resolve;
|
|
114
|
+
});
|
|
115
|
+
const upload = LocalArtifactStorage.prototype.upload;
|
|
116
|
+
const uploads = vi.spyOn(LocalArtifactStorage.prototype, "upload").mockImplementation(async function (
|
|
117
|
+
this: LocalArtifactStorage,
|
|
118
|
+
...args: Parameters<LocalArtifactStorage["upload"]>
|
|
119
|
+
) {
|
|
120
|
+
if (args[0] === artifactKey) await held;
|
|
121
|
+
return upload.apply(this, args);
|
|
122
|
+
});
|
|
123
|
+
const drain = StreamingSideEffects.prototype.drainPublishes;
|
|
124
|
+
const drains = vi.spyOn(StreamingSideEffects.prototype, "drainPublishes").mockImplementation(async function (
|
|
125
|
+
this: StreamingSideEffects,
|
|
126
|
+
) {
|
|
127
|
+
release();
|
|
128
|
+
return drain.apply(this);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── Act ──────────────────────────────────────────────────────────────────
|
|
132
|
+
const invocation = await runDeepAgentTurn(scenario);
|
|
133
|
+
release();
|
|
134
|
+
|
|
135
|
+
// ── Assert: the turn failed, deferred behind the review, and returned ────
|
|
136
|
+
expect(invocation.outcome.kind, "a provider failure RETURNS the status").toBe("returned");
|
|
137
|
+
const final = record.lastFullStatus!;
|
|
138
|
+
expect(final.phase, "the edit reaches review before the failure settles").toBe(
|
|
139
|
+
ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
|
|
140
|
+
);
|
|
141
|
+
expect(final.error, "a deferred terminal writes no error yet").toBe("");
|
|
142
|
+
expect(systemMessages(final), "the failure's rows ride the review").toEqual([
|
|
143
|
+
TERMINAL_COPY.internalFailure.row,
|
|
144
|
+
`Error details: [Error] ${PROVIDER_FAULT}`,
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
// ── Assert: the artifact reached the terminal status ─────────────────────
|
|
148
|
+
expect(
|
|
149
|
+
uploads.mock.calls.map(([key]) => key).filter((key) => key === artifactKey),
|
|
150
|
+
"the write was published inline, once",
|
|
151
|
+
).toHaveLength(1);
|
|
152
|
+
expect(final.artifacts.map((a) => [a.name, a.storageKey])).toEqual([[REPORT, artifactKey]]);
|
|
153
|
+
expect(drains, "the thrown turn drained its publishes").toHaveBeenCalled();
|
|
154
|
+
expect(existsSync(join(env.artifactPath, final.artifacts[0].storageKey)), "the bytes reached the store").toBe(true);
|
|
155
|
+
|
|
156
|
+
// ── Assert: hermeticity ──────────────────────────────────────────────────
|
|
157
|
+
expect(registry.urls.every((u) => u.includes("/model-registry"))).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { create } from "@bufbuild/protobuf";
|
|
7
7
|
import { type AgentExecutionStatus, AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
8
8
|
import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
9
|
+
import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
|
|
9
10
|
import { InlinePublisher } from "../inline-publisher.js";
|
|
10
11
|
import { TranscriptBuilder } from "../../../harness/transcript/builder.js";
|
|
11
12
|
import { LocalWorkspaceBackend } from "../../../shared/workspace/local-backend.js";
|
|
@@ -71,6 +72,7 @@ describe("InlinePublisher", () => {
|
|
|
71
72
|
workspaceBackend: backend,
|
|
72
73
|
artifactStorage: storage,
|
|
73
74
|
artifacts: sb,
|
|
75
|
+
record: status,
|
|
74
76
|
executionId: "exec-123",
|
|
75
77
|
});
|
|
76
78
|
});
|
|
@@ -97,6 +99,25 @@ describe("InlinePublisher", () => {
|
|
|
97
99
|
expect(status.artifacts[0].sandboxPath).toBe("src/main.ts");
|
|
98
100
|
});
|
|
99
101
|
|
|
102
|
+
it("stamps createdAt when the publish is asked for, never when the upload returns", async () => {
|
|
103
|
+
// The stream asks for the publish as it folds the write's finish, and the
|
|
104
|
+
// upload runs on beside the stream; a slow store must not move the stamp.
|
|
105
|
+
vi.useFakeTimers({ toFake: ["Date"], now: Date.UTC(2026, 0, 1) });
|
|
106
|
+
try {
|
|
107
|
+
const askedAt = new Date().toISOString();
|
|
108
|
+
vi.mocked(storage.upload).mockImplementationOnce(async (key: string) => {
|
|
109
|
+
vi.setSystemTime(Date.now() + 5_000);
|
|
110
|
+
return key;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await publisher.publish("src/main.ts");
|
|
114
|
+
|
|
115
|
+
expect(status.artifacts[0].createdAt).toBe(askedAt);
|
|
116
|
+
} finally {
|
|
117
|
+
vi.useRealTimers();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
100
121
|
it("deduplicates by path + content hash", async () => {
|
|
101
122
|
await publisher.publish("src/main.ts");
|
|
102
123
|
await publisher.publish("src/main.ts");
|
|
@@ -124,6 +145,64 @@ describe("InlinePublisher", () => {
|
|
|
124
145
|
expect(publisher.publishedPaths.has("src/main.ts")).toBe(true);
|
|
125
146
|
});
|
|
126
147
|
|
|
148
|
+
// A reinvocation seeds the earlier turns' rows onto the status before the
|
|
149
|
+
// turn's publisher exists; the safety net then asks for those paths again.
|
|
150
|
+
describe("against rows the status already lists (stigmer/stigmer#1448)", () => {
|
|
151
|
+
function seedRow(sandboxPath: string, content: string): void {
|
|
152
|
+
status.artifacts.push(create(ExecutionArtifactSchema, {
|
|
153
|
+
name: sandboxPath.split("/").at(-1) ?? sandboxPath,
|
|
154
|
+
sandboxPath,
|
|
155
|
+
kind: ExecutionArtifactKind.FILE,
|
|
156
|
+
storageKey: `artifacts/exec-123/${sandboxPath.split("/").at(-1)}`,
|
|
157
|
+
contentHash: sha256(content),
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
it("uploads nothing for a file whose bytes the status already lists, and counts it as brought up to date", async () => {
|
|
162
|
+
seedRow("src/main.ts", "console.log('hello');");
|
|
163
|
+
const seeded = status.artifacts[0];
|
|
164
|
+
|
|
165
|
+
await publisher.publish("src/main.ts");
|
|
166
|
+
|
|
167
|
+
expect(storage.uploadedKeys, "the unchanged file is not sent again").toEqual([]);
|
|
168
|
+
expect(status.artifacts, "the seeded row is kept as it was").toEqual([seeded]);
|
|
169
|
+
expect(publisher.publishedPaths.has("src/main.ts")).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("uploads and replaces the row when the file changed since the status listed it", async () => {
|
|
173
|
+
seedRow("src/main.ts", "an earlier turn's bytes");
|
|
174
|
+
|
|
175
|
+
await publisher.publish("src/main.ts");
|
|
176
|
+
|
|
177
|
+
expect(storage.uploadedKeys).toEqual(["artifacts/exec-123/main.ts"]);
|
|
178
|
+
expect(status.artifacts).toHaveLength(1);
|
|
179
|
+
expect(status.artifacts[0].contentHash).toBe(sha256("console.log('hello');"));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("still reads the file, so a rewrite made outside a write tool is caught", async () => {
|
|
183
|
+
seedRow("src/main.ts", "console.log('hello');");
|
|
184
|
+
|
|
185
|
+
await publisher.publish("src/main.ts");
|
|
186
|
+
|
|
187
|
+
expect(backend.readFile).toHaveBeenCalledWith("src/main.ts");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("leaves an earlier turn's path out of publishedPaths until this turn checks it", () => {
|
|
191
|
+
seedRow("src/main.ts", "console.log('hello');");
|
|
192
|
+
|
|
193
|
+
expect(publisher.publishedPaths.size).toBe(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("uploads a file the status lists under another path", async () => {
|
|
197
|
+
seedRow("docs/main.ts", "console.log('hello');");
|
|
198
|
+
|
|
199
|
+
await publisher.publish("src/main.ts");
|
|
200
|
+
|
|
201
|
+
expect(storage.uploadedKeys).toEqual(["artifacts/exec-123/main.ts"]);
|
|
202
|
+
expect(status.artifacts.map((a) => a.sandboxPath)).toEqual(["docs/main.ts", "src/main.ts"]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
127
206
|
it("swallows errors without throwing (fire-and-forget)", async () => {
|
|
128
207
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
129
208
|
const failBackend = mockWorkspaceBackend({});
|
|
@@ -132,6 +211,7 @@ describe("InlinePublisher", () => {
|
|
|
132
211
|
workspaceBackend: failBackend,
|
|
133
212
|
artifactStorage: storage,
|
|
134
213
|
artifacts: sb,
|
|
214
|
+
record: status,
|
|
135
215
|
executionId: "exec-err",
|
|
136
216
|
});
|
|
137
217
|
|
|
@@ -176,6 +256,7 @@ describe("InlinePublisher", () => {
|
|
|
176
256
|
workspaceBackend: secretBackend,
|
|
177
257
|
artifactStorage: storage,
|
|
178
258
|
artifacts: sb,
|
|
259
|
+
record: status,
|
|
179
260
|
executionId: "exec-secret",
|
|
180
261
|
});
|
|
181
262
|
|
|
@@ -193,6 +274,7 @@ describe("InlinePublisher", () => {
|
|
|
193
274
|
workspaceBackend: jsonBackend,
|
|
194
275
|
artifactStorage: storage,
|
|
195
276
|
artifacts: sb,
|
|
277
|
+
record: status,
|
|
196
278
|
executionId: "exec-ct",
|
|
197
279
|
});
|
|
198
280
|
|
|
@@ -221,6 +303,7 @@ describe("InlinePublisher with LocalWorkspaceBackend (disk-backed)", () => {
|
|
|
221
303
|
workspaceBackend: backend,
|
|
222
304
|
artifactStorage: storage,
|
|
223
305
|
artifacts: sb,
|
|
306
|
+
record: status,
|
|
224
307
|
executionId: "exec-disk",
|
|
225
308
|
});
|
|
226
309
|
|
|
@@ -250,6 +333,7 @@ describe("InlinePublisher with LocalWorkspaceBackend (disk-backed)", () => {
|
|
|
250
333
|
workspaceBackend: backend,
|
|
251
334
|
artifactStorage: storage,
|
|
252
335
|
artifacts: sb,
|
|
336
|
+
record: status,
|
|
253
337
|
executionId: "exec-miss",
|
|
254
338
|
});
|
|
255
339
|
|
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
// builds (`createCasCaptureBackend` + `mountPlatformRoute`, parent and
|
|
4
4
|
// sub-agents alike). The stock virtual-rooted backend refuses to follow the
|
|
5
5
|
// workspace link out of the root; the route mounts the platform dir instead,
|
|
6
|
-
// read-only, on the turns the link exists
|
|
6
|
+
// read-only, on the turns the link exists, and an empty route on every other
|
|
7
|
+
// turn. What is pinned:
|
|
7
8
|
// - the stock backend's refusal (why the route exists);
|
|
8
9
|
// - reads, listings and search through the route, for the shell-capable and
|
|
9
10
|
// the plan-mode backend, with `execute` kept exactly where it was;
|
|
10
11
|
// - every mutation through the route refused, and a root delete refused
|
|
11
12
|
// before it touches the workspace or the platform dir;
|
|
12
|
-
// -
|
|
13
|
+
// - on a turn without the link, `.stigmer/` still the platform's: nothing
|
|
14
|
+
// readable, every write refused and nothing created in the workspace (the
|
|
15
|
+
// write that used to make a real directory there, #1123), and the root
|
|
16
|
+
// listing without it.
|
|
13
17
|
|
|
14
18
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
15
|
-
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
19
|
+
import { lstat, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
20
|
import { tmpdir } from "node:os";
|
|
17
21
|
import { join } from "node:path";
|
|
18
22
|
import { FilesystemBackend, isSandboxBackend } from "deepagents";
|
|
@@ -120,23 +124,56 @@ describe("platform route", () => {
|
|
|
120
124
|
});
|
|
121
125
|
});
|
|
122
126
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
+
describe.each(["shell", "plan"] as const)("the %s backend on a turn without the link", (mode) => {
|
|
128
|
+
it("refuses every write under .stigmer/ and creates nothing in the workspace", async () => {
|
|
129
|
+
const backend = v2(await productionBackend(mode));
|
|
130
|
+
expect(backend).toBeInstanceOf(PlatformRoutedBackend);
|
|
131
|
+
expect((await backend.write(`${PLATFORM_ROUTE_PREFIX}notes/draft.md`, "x")).error).toMatch(/read-only/);
|
|
132
|
+
expect((await backend.edit(`/${SKILL}`, "My Skill", "Your Skill")).error).toMatch(/read-only/);
|
|
133
|
+
const uploads = (await backend.uploadFiles!([[`${PLATFORM_ROUTE_PREFIX}up.bin`, new Uint8Array([1])]])) ?? [];
|
|
134
|
+
expect(uploads.map((u) => u.error)).toEqual(["permission_denied"]);
|
|
135
|
+
await expect(lstat(join(workspaceDir, ".stigmer")), "no real directory in the user's tree").rejects.toMatchObject({
|
|
136
|
+
code: "ENOENT",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("exposes nothing of the platform dir, which still holds earlier turns' content", async () => {
|
|
141
|
+
const backend = v2(await productionBackend(mode));
|
|
142
|
+
expect((await backend.read(`/${SKILL}`)).error).toMatch(/no platform content/);
|
|
143
|
+
expect((await backend.ls(PLATFORM_ROUTE_PREFIX)).error).toMatch(/no platform content/);
|
|
144
|
+
const found = (await backend.glob("**/SKILL.md", "/")).files ?? [];
|
|
145
|
+
expect(found).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("lists the workspace at the root without .stigmer/", async () => {
|
|
149
|
+
const backend = v2(await productionBackend(mode));
|
|
150
|
+
const root = ((await backend.ls("/")).files ?? []).map((f) => f.path);
|
|
151
|
+
expect(root).toContain("/main.go");
|
|
152
|
+
expect(root).not.toContain(PLATFORM_ROUTE_PREFIX);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps `execute` exactly where the workspace backend had it", async () => {
|
|
156
|
+
const backend = await productionBackend(mode);
|
|
157
|
+
expect(isSandboxBackend(backend)).toBe(mode === "shell");
|
|
158
|
+
});
|
|
127
159
|
});
|
|
128
160
|
|
|
129
|
-
it("
|
|
161
|
+
it("exposes nothing when the link points somewhere else", async () => {
|
|
130
162
|
const other = await mkdtemp(join(tmpdir(), "platform-route-other-"));
|
|
131
163
|
try {
|
|
132
164
|
await ensureStigmerSymlink(workspaceDir, other);
|
|
133
|
-
const
|
|
134
|
-
expect(await
|
|
165
|
+
const backend = v2(await productionBackend("plan"));
|
|
166
|
+
expect((await backend.read(`/${SKILL}`)).error).toMatch(/no platform content/);
|
|
135
167
|
} finally {
|
|
136
168
|
await rm(other, { recursive: true, force: true });
|
|
137
169
|
}
|
|
138
170
|
});
|
|
139
171
|
|
|
172
|
+
it("mounts nothing for a session without a platform dir", async () => {
|
|
173
|
+
const workspace = await createCasCaptureBackend({ rootDir: workspaceDir, observer: observer(), shellEnv: {} });
|
|
174
|
+
expect(await mountPlatformRoute(workspace, { workspaceDir, platformDir: undefined })).toBe(workspace);
|
|
175
|
+
});
|
|
176
|
+
|
|
140
177
|
it("a workspace entry of its own is untouched by the route", async () => {
|
|
141
178
|
await mkdir(join(workspaceDir, "src"));
|
|
142
179
|
await writeFile(join(workspaceDir, "src/a.ts"), "export {};\n");
|
|
@@ -76,6 +76,40 @@ describe("StreamingSideEffects", () => {
|
|
|
76
76
|
expect(publish).not.toHaveBeenCalled();
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
it("drainPublishes waits for every publish fired so far, and a second drain returns at once", async () => {
|
|
80
|
+
resetSeq();
|
|
81
|
+
const landed: string[] = [];
|
|
82
|
+
const releases: Array<() => void> = [];
|
|
83
|
+
const publish = vi.fn(
|
|
84
|
+
(path: string) =>
|
|
85
|
+
new Promise<void>((resolve) => {
|
|
86
|
+
releases.push(() => {
|
|
87
|
+
landed.push(path);
|
|
88
|
+
resolve();
|
|
89
|
+
});
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
const effects = new StreamingSideEffects({ inlinePublisher: { publish } as unknown as InlinePublisher });
|
|
93
|
+
feed(effects, makeToolStarted("toolu_7", "write_file", { file_path: "/ws/a.ts", content: "a" }));
|
|
94
|
+
feed(effects, makeToolFinished("toolu_7", "ok"));
|
|
95
|
+
feed(effects, makeToolStarted("toolu_8", "write_file", { file_path: "/ws/b.ts", content: "b" }));
|
|
96
|
+
feed(effects, makeToolFinished("toolu_8", "ok"));
|
|
97
|
+
|
|
98
|
+
let drained = false;
|
|
99
|
+
const drain = effects.drainPublishes().then(() => {
|
|
100
|
+
drained = true;
|
|
101
|
+
});
|
|
102
|
+
releases[0]();
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
expect(drained, "one publish still in flight holds the drain").toBe(false);
|
|
105
|
+
releases[1]();
|
|
106
|
+
await drain;
|
|
107
|
+
expect(landed).toEqual(["/ws/a.ts", "/ws/b.ts"]);
|
|
108
|
+
|
|
109
|
+
await effects.drainPublishes();
|
|
110
|
+
expect(publish, "a second drain fires nothing new").toHaveBeenCalledTimes(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
79
113
|
it("does nothing at all without a publisher", () => {
|
|
80
114
|
resetSeq();
|
|
81
115
|
const effects = new StreamingSideEffects({});
|
|
@@ -45,7 +45,7 @@ import type { WorkspaceBackend } from "../../../shared/workspace/types.js";
|
|
|
45
45
|
import type { ModelPricing } from "../../../shared/model-pricing.js";
|
|
46
46
|
import { CasCaptureObserver } from "../cas-capture-observer.js";
|
|
47
47
|
import type { DeepAgentEngine, DeepAgentGateState, DeepAgentGraphInput, DeepAgentWorkspace } from "../turn-setup.js";
|
|
48
|
-
import { consumeDeepAgentStream, createDeepAgentTranscript, type StreamableRun } from "../turn-stream.js";
|
|
48
|
+
import { consumeDeepAgentStream, createDeepAgentTranscript, type DeepAgentTranscript, type StreamableRun } from "../turn-stream.js";
|
|
49
49
|
import type { V3ProtocolEvent } from "../v3-event-recorder.js";
|
|
50
50
|
import {
|
|
51
51
|
makeMessageFinish,
|
|
@@ -129,6 +129,8 @@ interface Harness {
|
|
|
129
129
|
readonly engine: DeepAgentEngine;
|
|
130
130
|
readonly streamEvents: ReturnType<typeof vi.fn>;
|
|
131
131
|
readonly blobs: Map<string, Buffer>;
|
|
132
|
+
/** The transcript the last `run` streamed into; its side effects hold the turn's publishes. */
|
|
133
|
+
readonly transcript: () => DeepAgentTranscript;
|
|
132
134
|
run(run: StreamableRun): ReturnType<typeof consumeDeepAgentStream>;
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -150,14 +152,19 @@ function harness(options: { gate?: DeepAgentGateState; files?: Record<string, st
|
|
|
150
152
|
};
|
|
151
153
|
const workspace = workspaceOver(options.files ?? {});
|
|
152
154
|
const graphInput: DeepAgentGraphInput = { kind: "message", input: { messages: [{ role: "user", content: "Say hello." }] } };
|
|
155
|
+
let transcript: DeepAgentTranscript | undefined;
|
|
153
156
|
return {
|
|
154
157
|
sink,
|
|
155
158
|
engine,
|
|
156
159
|
streamEvents,
|
|
157
160
|
blobs,
|
|
161
|
+
transcript: () => {
|
|
162
|
+
if (transcript === undefined) throw new Error("harness: no run yet");
|
|
163
|
+
return transcript;
|
|
164
|
+
},
|
|
158
165
|
run(run) {
|
|
159
166
|
streamEvents.mockResolvedValue(run);
|
|
160
|
-
|
|
167
|
+
transcript = createDeepAgentTranscript(input, sink, engine, workspace);
|
|
161
168
|
return consumeDeepAgentStream({ input, sink, engine, graphInput, transcript });
|
|
162
169
|
},
|
|
163
170
|
};
|
|
@@ -245,7 +252,7 @@ describe("consumeDeepAgentStream — what each event becomes", () => {
|
|
|
245
252
|
|
|
246
253
|
it("publishes a file-modifying tool's target as an artifact on the status when the call finishes", async () => {
|
|
247
254
|
const h = harness({ files: { "src/main.ts": "console.log('hi');" } });
|
|
248
|
-
|
|
255
|
+
await h.run(
|
|
249
256
|
scriptedRun([
|
|
250
257
|
...textTurn("run-1", "Writing."),
|
|
251
258
|
makeToolStarted("toolu_w", "write_file", { file_path: "src/main.ts", content: "console.log('hi');" }),
|
|
@@ -253,8 +260,9 @@ describe("consumeDeepAgentStream — what each event becomes", () => {
|
|
|
253
260
|
]),
|
|
254
261
|
);
|
|
255
262
|
|
|
256
|
-
|
|
257
|
-
|
|
263
|
+
const { sideEffects } = h.transcript();
|
|
264
|
+
expect(sideEffects.pendingPublishPromises, "one publish fired mid-turn, held by the transcript for the drain").toHaveLength(1);
|
|
265
|
+
await sideEffects.drainPublishes();
|
|
258
266
|
expect(h.sink.status.artifacts.map((a) => a.sandboxPath)).toEqual(["src/main.ts"]);
|
|
259
267
|
expect([...h.blobs.keys()], "the bytes were uploaded under the execution's artifact key").toEqual(["artifacts/aex_fixture_0001/main.ts"]);
|
|
260
268
|
});
|
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* Designed as a fire-and-forget callback: exceptions are logged and
|
|
9
9
|
* swallowed so the streaming loop is never interrupted.
|
|
10
10
|
*
|
|
11
|
+
* A file is uploaded only when its bytes differ from the row the execution's
|
|
12
|
+
* status already lists for its path. The status is the one home of that fact:
|
|
13
|
+
* the builder writes every row into it, and a reinvocation's seed carries the
|
|
14
|
+
* earlier turns' rows onto it before this publisher exists. So a completed
|
|
15
|
+
* turn after a gate, whose safety net walks the seeded history again, re-reads
|
|
16
|
+
* each earlier file but uploads none that did not change (stigmer/stigmer#1448).
|
|
17
|
+
* The re-read is kept on purpose: a shell command may have rewritten an earlier
|
|
18
|
+
* turn's file, and only its bytes can say so.
|
|
19
|
+
*
|
|
11
20
|
* DD-7: No skill-aware directory publishing. Individual files only.
|
|
12
21
|
*/
|
|
13
22
|
|
|
@@ -35,6 +44,16 @@ export interface ArtifactSink {
|
|
|
35
44
|
addArtifact(artifact: ExecutionArtifact): void;
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The execution status's artifact rows, read at every publish: the status the
|
|
49
|
+
* {@link ArtifactSink} builds into (`TurnSink.status` in production), so a row
|
|
50
|
+
* registered here or seeded by a reinvocation is visible on the next check.
|
|
51
|
+
* Read, never copied, because the builder keeps writing it.
|
|
52
|
+
*/
|
|
53
|
+
export interface ArtifactRecord {
|
|
54
|
+
readonly artifacts: readonly ExecutionArtifact[];
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
export class InlinePublisher {
|
|
39
58
|
private readonly workspaceBackend: WorkspaceBackend;
|
|
40
59
|
/**
|
|
@@ -45,33 +64,46 @@ export class InlinePublisher {
|
|
|
45
64
|
*/
|
|
46
65
|
private readonly artifactStorage: ArtifactStorage | undefined;
|
|
47
66
|
private readonly artifacts: ArtifactSink;
|
|
67
|
+
private readonly record: ArtifactRecord;
|
|
48
68
|
private readonly executionId: string;
|
|
49
69
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Paths this publisher brought up to date in this turn, uploaded or found
|
|
72
|
+
* unchanged, so the post-stream safety net does not read them again. Per
|
|
73
|
+
* turn by design: an earlier turn's path is absent until this turn checks
|
|
74
|
+
* it, so a file rewritten outside a write tool is still caught.
|
|
75
|
+
*/
|
|
76
|
+
private readonly published = new Set<string>();
|
|
52
77
|
|
|
53
78
|
constructor(opts: {
|
|
54
79
|
workspaceBackend: WorkspaceBackend;
|
|
55
80
|
artifactStorage: ArtifactStorage | undefined;
|
|
56
81
|
artifacts: ArtifactSink;
|
|
82
|
+
record: ArtifactRecord;
|
|
57
83
|
executionId: string;
|
|
58
84
|
}) {
|
|
59
85
|
this.workspaceBackend = opts.workspaceBackend;
|
|
60
86
|
this.artifactStorage = opts.artifactStorage;
|
|
61
87
|
this.artifacts = opts.artifacts;
|
|
88
|
+
this.record = opts.record;
|
|
62
89
|
this.executionId = opts.executionId;
|
|
63
90
|
}
|
|
64
91
|
|
|
65
|
-
/** Set of sandbox paths
|
|
92
|
+
/** Set of sandbox paths brought up to date this turn (for auto-publish dedup). */
|
|
66
93
|
get publishedPaths(): ReadonlySet<string> {
|
|
67
|
-
return new Set(this.published
|
|
94
|
+
return new Set(this.published);
|
|
68
95
|
}
|
|
69
96
|
|
|
70
97
|
/**
|
|
71
98
|
* Upload the file at `path` to artifact storage and register it on the
|
|
72
99
|
* status builder. Fire-and-forget: errors are logged and swallowed.
|
|
100
|
+
*
|
|
101
|
+
* The artifact's `createdAt` is read here, before the first await: the
|
|
102
|
+
* stream asks for the publish in the step that folds the write's finish, so
|
|
103
|
+
* the stamp is the write's, never the moment a slow upload returned.
|
|
73
104
|
*/
|
|
74
105
|
async publish(path: string): Promise<void> {
|
|
106
|
+
const createdAt = utcTimestamp();
|
|
75
107
|
// No artifact store (proxy misconfig): nothing to upload to. Skip silently —
|
|
76
108
|
// this is a best-effort UI publisher and the operator was warned at setup.
|
|
77
109
|
if (!this.artifactStorage) return;
|
|
@@ -97,7 +129,8 @@ export class InlinePublisher {
|
|
|
97
129
|
const contentBuffer = Buffer.from(content, "utf-8");
|
|
98
130
|
const contentHash = sha256(contentBuffer);
|
|
99
131
|
|
|
100
|
-
if (this.
|
|
132
|
+
if (this.isRecorded(sandboxPath, contentHash)) {
|
|
133
|
+
this.published.add(sandboxPath);
|
|
101
134
|
return;
|
|
102
135
|
}
|
|
103
136
|
|
|
@@ -112,12 +145,12 @@ export class InlinePublisher {
|
|
|
112
145
|
kind: ExecutionArtifactKind.FILE,
|
|
113
146
|
sizeBytes: BigInt(contentBuffer.length),
|
|
114
147
|
storageKey,
|
|
115
|
-
createdAt
|
|
148
|
+
createdAt,
|
|
116
149
|
contentHash,
|
|
117
150
|
});
|
|
118
151
|
|
|
119
152
|
this.artifacts.addArtifact(artifact);
|
|
120
|
-
this.published.
|
|
153
|
+
this.published.add(sandboxPath);
|
|
121
154
|
|
|
122
155
|
console.log(
|
|
123
156
|
`[InlinePublisher] execution=${this.executionId} — published '${sandboxPath}' ` +
|
|
@@ -130,6 +163,13 @@ export class InlinePublisher {
|
|
|
130
163
|
);
|
|
131
164
|
}
|
|
132
165
|
}
|
|
166
|
+
|
|
167
|
+
/** Whether the status already lists these exact bytes at this path. */
|
|
168
|
+
private isRecorded(sandboxPath: string, contentHash: string): boolean {
|
|
169
|
+
return this.record.artifacts.some(
|
|
170
|
+
(a) => a.sandboxPath === sandboxPath && a.contentHash === contentHash,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
133
173
|
}
|
|
134
174
|
|
|
135
175
|
function normalizePath(path: string): string {
|