@stigmer/runner 3.1.4 → 3.1.6
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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +40 -2
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js +1 -1
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-provision.d.ts +23 -4
- package/dist/activities/execute-cursor/workspace-provision.js +17 -6
- package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +39 -6
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/inline-publisher.d.ts +1 -1
- package/dist/activities/execute-deep-agent/post-stream.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +1 -1
- package/dist/shared/execution-status-writer.d.ts +31 -0
- package/dist/shared/execution-status-writer.js +43 -0
- package/dist/shared/execution-status-writer.js.map +1 -0
- package/dist/shared/filereview/git-substrate.js +4 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/workspace/git-identity.d.ts +36 -0
- package/dist/shared/workspace/git-identity.js +39 -0
- package/dist/shared/workspace/git-identity.js.map +1 -0
- package/dist/shared/workspace/local-backend.d.ts +6 -2
- package/dist/shared/workspace/local-backend.js +6 -2
- package/dist/shared/workspace/local-backend.js.map +1 -1
- package/dist/shared/workspace/stigmer-link.d.ts +54 -0
- package/dist/shared/workspace/stigmer-link.js +92 -0
- package/dist/shared/workspace/stigmer-link.js.map +1 -0
- package/dist/shared/workspace/types.d.ts +6 -4
- package/dist/shared/workspace/writeback-coordinator.d.ts +120 -0
- package/dist/shared/workspace/writeback-coordinator.js +393 -0
- package/dist/shared/workspace/writeback-coordinator.js.map +1 -0
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +23 -15
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/index.ts +42 -2
- package/src/activities/execute-cursor/skill-resolver.ts +1 -1
- package/src/activities/execute-cursor/workspace-provision.ts +39 -7
- package/src/activities/execute-deep-agent/__tests__/post-stream.test.ts +1 -1
- package/src/activities/execute-deep-agent/index.ts +44 -6
- package/src/activities/execute-deep-agent/inline-publisher.ts +1 -1
- package/src/activities/execute-deep-agent/post-stream.ts +1 -1
- package/src/activities/execute-deep-agent/status-builder.ts +1 -1
- package/src/activities/execute-deep-agent/streaming-side-effects.ts +1 -1
- package/src/activities/execute-deep-agent/streaming-terminal.ts +1 -1
- package/src/activities/execute-deep-agent/streaming.ts +1 -1
- package/src/activities/execute-deep-agent/v3-status-builder.ts +1 -1
- package/src/shared/execution-status-writer.ts +56 -0
- package/src/shared/filereview/git-substrate.ts +4 -0
- package/src/shared/workspace/__tests__/git-identity.test.ts +124 -0
- package/src/shared/workspace/__tests__/stigmer-link.test.ts +173 -0
- package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +230 -0
- package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +477 -0
- package/src/shared/workspace/git-identity.ts +41 -0
- package/src/shared/workspace/local-backend.ts +6 -2
- package/src/shared/workspace/stigmer-link.ts +97 -0
- package/src/shared/workspace/types.ts +6 -4
- package/src/{activities/execute-deep-agent → shared/workspace}/writeback-coordinator.ts +209 -93
- package/dist/activities/execute-cursor/stigmer-link.d.ts +0 -35
- package/dist/activities/execute-cursor/stigmer-link.js +0 -73
- package/dist/activities/execute-cursor/stigmer-link.js.map +0 -1
- package/dist/activities/execute-deep-agent/execution-status-writer.d.ts +0 -17
- package/dist/activities/execute-deep-agent/execution-status-writer.js +0 -9
- package/dist/activities/execute-deep-agent/execution-status-writer.js.map +0 -1
- package/dist/activities/execute-deep-agent/writeback-coordinator.d.ts +0 -71
- package/dist/activities/execute-deep-agent/writeback-coordinator.js +0 -295
- package/dist/activities/execute-deep-agent/writeback-coordinator.js.map +0 -1
- package/src/activities/execute-cursor/stigmer-link.ts +0 -78
- package/src/activities/execute-deep-agent/__tests__/writeback-coordinator.test.ts +0 -399
- package/src/activities/execute-deep-agent/execution-status-writer.ts +0 -19
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
4
|
+
import { WorkspaceWriteBackPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
|
|
5
|
+
import { GitWriteBackMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
6
|
+
import {
|
|
7
|
+
statusProtoWriter,
|
|
8
|
+
type ExecutionStatusWriter,
|
|
9
|
+
} from "../../execution-status-writer.js";
|
|
10
|
+
import {
|
|
11
|
+
WriteBackCoordinator,
|
|
12
|
+
parseGithubRepo,
|
|
13
|
+
} from "../writeback-coordinator.js";
|
|
14
|
+
import type { WorkspaceBackend, ProvisionResult } from "../types.js";
|
|
15
|
+
import { SourceType } from "../types.js";
|
|
16
|
+
import {
|
|
17
|
+
AGENT_GIT_AUTHOR_NAME,
|
|
18
|
+
AGENT_GIT_AUTHOR_EMAIL,
|
|
19
|
+
} from "../git-identity.js";
|
|
20
|
+
|
|
21
|
+
const SESSION_ID = "ses-01test";
|
|
22
|
+
const SESSION_BRANCH = `stigmer/${SESSION_ID}`;
|
|
23
|
+
|
|
24
|
+
// The shared proto-backed writer — the same implementation the Cursor harness
|
|
25
|
+
// wires in, and upsert-compatible with the deep-agent StatusBuilder.
|
|
26
|
+
function makeStatusBuilder(): ExecutionStatusWriter {
|
|
27
|
+
return statusProtoWriter(create(AgentExecutionStatusSchema, {}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeProvisionResult(overrides: Partial<ProvisionResult> = {}): ProvisionResult {
|
|
31
|
+
return {
|
|
32
|
+
rootDir: "/workspace/my-app",
|
|
33
|
+
sourceType: SourceType.GIT_REPO,
|
|
34
|
+
consumedKeys: [],
|
|
35
|
+
workspaceDescription: "test",
|
|
36
|
+
entryName: "my-app",
|
|
37
|
+
gitMetadata: {
|
|
38
|
+
// Token-stripped, as extractGitMetadata always records it.
|
|
39
|
+
repoUrl: "https://github.com/acme/my-app.git",
|
|
40
|
+
branch: "main",
|
|
41
|
+
baseCommit: "abc123",
|
|
42
|
+
gitCredentialsConfigured: true,
|
|
43
|
+
},
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeWorkspaceEntry(name: string, writeBackMode: GitWriteBackMode = GitWriteBackMode.GIT_WRITE_BACK_BRANCH_AND_PR) {
|
|
49
|
+
return {
|
|
50
|
+
name,
|
|
51
|
+
source: {
|
|
52
|
+
source: {
|
|
53
|
+
case: "gitRepo" as const,
|
|
54
|
+
value: { writeBackMode },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
$typeName: "ai.stigmer.agentic.session.v1.WorkspaceEntry" as const,
|
|
58
|
+
} as any;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mockWorkspaceBackend(responses: Record<string, string> = {}): WorkspaceBackend {
|
|
62
|
+
const defaultResponses: Record<string, string> = {
|
|
63
|
+
"git diff --stat": " 1 file changed, 1 insertion(+)",
|
|
64
|
+
"git diff --cached --stat": "",
|
|
65
|
+
"git ls-files --others --exclude-standard": "",
|
|
66
|
+
// Fresh clone on the base branch: the session branch exists nowhere yet.
|
|
67
|
+
"git branch --show-current": "main",
|
|
68
|
+
"git rev-parse --verify --quiet": "",
|
|
69
|
+
"git ls-remote --heads origin": "",
|
|
70
|
+
"git checkout": "",
|
|
71
|
+
"git add -A": "",
|
|
72
|
+
"commit -m": "",
|
|
73
|
+
"git rev-parse HEAD": "abc123def456",
|
|
74
|
+
"git push": "",
|
|
75
|
+
"git diff --stat main...HEAD": " 1 file changed",
|
|
76
|
+
...responses,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
rootDir: "/workspace",
|
|
81
|
+
execute: vi.fn(async (cmd: string) => {
|
|
82
|
+
for (const [pattern, response] of Object.entries(defaultResponses)) {
|
|
83
|
+
if (cmd.includes(pattern)) return response;
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}),
|
|
87
|
+
readFile: vi.fn(),
|
|
88
|
+
writeFile: vi.fn(),
|
|
89
|
+
writeFileBuffer: vi.fn(),
|
|
90
|
+
exists: vi.fn(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeCoordinator(opts: {
|
|
95
|
+
sb: ExecutionStatusWriter;
|
|
96
|
+
backend?: WorkspaceBackend;
|
|
97
|
+
executionId?: string;
|
|
98
|
+
sessionId?: string;
|
|
99
|
+
githubToken?: string;
|
|
100
|
+
provisionResults?: ProvisionResult[];
|
|
101
|
+
workspaceEntries?: any[];
|
|
102
|
+
}): WriteBackCoordinator {
|
|
103
|
+
return new WriteBackCoordinator({
|
|
104
|
+
statusWriter: opts.sb,
|
|
105
|
+
executionId: opts.executionId ?? "exec-12345678rest",
|
|
106
|
+
sessionId: opts.sessionId ?? SESSION_ID,
|
|
107
|
+
githubToken: opts.githubToken ?? "ghp_plumbed_token",
|
|
108
|
+
provisionResults: opts.provisionResults ?? [makeProvisionResult()],
|
|
109
|
+
workspaceEntries: opts.workspaceEntries ?? [makeWorkspaceEntry("my-app")],
|
|
110
|
+
workspaceBackend: opts.backend ?? mockWorkspaceBackend(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* URL-aware GitHub API mock: listing open PRs for the head branch returns
|
|
116
|
+
* `openPrs`; creating a PR returns `created`. Records calls for assertions.
|
|
117
|
+
*/
|
|
118
|
+
function mockGithubApi(opts: {
|
|
119
|
+
openPrs?: Array<{ html_url: string; number: number }>;
|
|
120
|
+
created?: { html_url: string; number: number };
|
|
121
|
+
createStatus?: number;
|
|
122
|
+
} = {}) {
|
|
123
|
+
const calls: Array<{ url: string; method: string; headers: Record<string, string> }> = [];
|
|
124
|
+
globalThis.fetch = vi.fn(async (url: any, init?: any) => {
|
|
125
|
+
const method = init?.method ?? "GET";
|
|
126
|
+
calls.push({ url: String(url), method, headers: init?.headers ?? {} });
|
|
127
|
+
if (method === "GET") {
|
|
128
|
+
return new Response(JSON.stringify(opts.openPrs ?? []), { status: 200 });
|
|
129
|
+
}
|
|
130
|
+
const status = opts.createStatus ?? 201;
|
|
131
|
+
const body = status < 300
|
|
132
|
+
? JSON.stringify(opts.created ?? { html_url: "https://github.com/acme/my-app/pull/42", number: 42 })
|
|
133
|
+
: JSON.stringify({ message: "Forbidden" });
|
|
134
|
+
return new Response(body, { status });
|
|
135
|
+
}) as typeof fetch;
|
|
136
|
+
return calls;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const originalFetch = globalThis.fetch;
|
|
140
|
+
|
|
141
|
+
describe("WriteBackCoordinator", () => {
|
|
142
|
+
let sb: ExecutionStatusWriter;
|
|
143
|
+
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
sb = makeStatusBuilder();
|
|
146
|
+
mockGithubApi();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
globalThis.fetch = originalFetch;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── Eligibility ─────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it("filters out non-git workspace entries", () => {
|
|
156
|
+
const coord = makeCoordinator({
|
|
157
|
+
sb,
|
|
158
|
+
provisionResults: [makeProvisionResult({ sourceType: SourceType.LOCAL_PATH })],
|
|
159
|
+
});
|
|
160
|
+
expect(coord.hasEligibleEntries).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("filters out entries without git credentials", () => {
|
|
164
|
+
const coord = makeCoordinator({
|
|
165
|
+
sb,
|
|
166
|
+
provisionResults: [makeProvisionResult({
|
|
167
|
+
gitMetadata: {
|
|
168
|
+
repoUrl: "https://github.com/acme/my-app.git",
|
|
169
|
+
branch: "main",
|
|
170
|
+
baseCommit: "abc",
|
|
171
|
+
gitCredentialsConfigured: false,
|
|
172
|
+
},
|
|
173
|
+
})],
|
|
174
|
+
});
|
|
175
|
+
expect(coord.hasEligibleEntries).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("accepts entries with UNSPECIFIED write-back mode (platform decides)", () => {
|
|
179
|
+
const coord = makeCoordinator({
|
|
180
|
+
sb,
|
|
181
|
+
workspaceEntries: [makeWorkspaceEntry("my-app", GitWriteBackMode.GIT_WRITE_BACK_MODE_UNSPECIFIED)],
|
|
182
|
+
});
|
|
183
|
+
expect(coord.hasEligibleEntries).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── Full cycle ──────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
it("performs full cycle on the SESSION branch: branch -> commit -> push -> PR", async () => {
|
|
189
|
+
const backend = mockWorkspaceBackend();
|
|
190
|
+
const coord = makeCoordinator({ sb, backend });
|
|
191
|
+
|
|
192
|
+
await coord.onFileModified("src/main.ts");
|
|
193
|
+
|
|
194
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
195
|
+
expect(wbs).toHaveLength(1);
|
|
196
|
+
expect(wbs[0].branchName).toBe(SESSION_BRANCH);
|
|
197
|
+
expect(wbs[0].baseBranch).toBe("main");
|
|
198
|
+
expect(wbs[0].commitSha).toBe("abc123def456");
|
|
199
|
+
expect(wbs[0].pullRequestUrl).toBe("https://github.com/acme/my-app/pull/42");
|
|
200
|
+
expect(wbs[0].pullRequestNumber).toBe(42);
|
|
201
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
|
|
202
|
+
expect(wbs[0].error).toBe("");
|
|
203
|
+
|
|
204
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
205
|
+
.map((c: any) => c[0] as string);
|
|
206
|
+
expect(commands.some((c: string) => c.includes(`git checkout -b ${SESSION_BRANCH}`))).toBe(true);
|
|
207
|
+
expect(commands.some((c: string) => c.includes("git add -A"))).toBe(true);
|
|
208
|
+
expect(commands.some((c: string) => c.includes("commit -m"))).toBe(true);
|
|
209
|
+
expect(commands.some((c: string) => c.includes(`git push -u origin ${SESSION_BRANCH}`))).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("commits with the agent identity pinned via -c flags and the execution id in the message", async () => {
|
|
213
|
+
const backend = mockWorkspaceBackend();
|
|
214
|
+
const coord = makeCoordinator({ sb, backend, executionId: "exec-12345678rest" });
|
|
215
|
+
|
|
216
|
+
await coord.onFileModified("src/main.ts");
|
|
217
|
+
|
|
218
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
219
|
+
.map((c: any) => c[0] as string);
|
|
220
|
+
const commitCommand = commands.find((c: string) => c.includes("git") && c.includes("commit -m"));
|
|
221
|
+
expect(commitCommand,
|
|
222
|
+
"commit must not depend on ambient git identity — the cloud sandbox has none",
|
|
223
|
+
).toBeDefined();
|
|
224
|
+
expect(commitCommand).toContain(`-c user.name='${AGENT_GIT_AUTHOR_NAME}'`);
|
|
225
|
+
expect(commitCommand).toContain(`-c user.email='${AGENT_GIT_AUTHOR_EMAIL}'`);
|
|
226
|
+
expect(commitCommand).toContain('commit -m "agent changes (exec-12345678rest)"');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("skips when there are no changes", async () => {
|
|
230
|
+
const backend = mockWorkspaceBackend({
|
|
231
|
+
"git diff --stat": "",
|
|
232
|
+
"git ls-files --others --exclude-standard": "",
|
|
233
|
+
});
|
|
234
|
+
const coord = makeCoordinator({ sb, backend });
|
|
235
|
+
|
|
236
|
+
await coord.onFileModified("src/main.ts");
|
|
237
|
+
|
|
238
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── Session-branch idempotency ──────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
it("on second call, commits to the existing branch without re-creating", async () => {
|
|
244
|
+
const backend = mockWorkspaceBackend();
|
|
245
|
+
const coord = makeCoordinator({ sb, backend });
|
|
246
|
+
|
|
247
|
+
await coord.onFileModified("first.ts");
|
|
248
|
+
await coord.onFileModified("second.ts");
|
|
249
|
+
|
|
250
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
251
|
+
.map((c: any) => c[0] as string);
|
|
252
|
+
const checkoutCalls = commands.filter((c: string) => c.includes("git checkout -b"));
|
|
253
|
+
expect(checkoutCalls).toHaveLength(1);
|
|
254
|
+
|
|
255
|
+
const pushCalls = commands.filter((c: string) => c.includes("git push"));
|
|
256
|
+
expect(pushCalls.length).toBeGreaterThanOrEqual(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("a later execution reuses the session branch HEAD already sits on (no checkout)", async () => {
|
|
260
|
+
// Turn 2 of the same session: the previous cycle left HEAD on the branch.
|
|
261
|
+
const backend = mockWorkspaceBackend({
|
|
262
|
+
"git branch --show-current": SESSION_BRANCH,
|
|
263
|
+
});
|
|
264
|
+
const coord = makeCoordinator({ sb, backend, executionId: "exec-turn2" });
|
|
265
|
+
|
|
266
|
+
await coord.onFileModified("src/more.ts");
|
|
267
|
+
|
|
268
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
269
|
+
.map((c: any) => c[0] as string);
|
|
270
|
+
expect(commands.some((c: string) => c.includes("git checkout"))).toBe(false);
|
|
271
|
+
expect(commands.some((c: string) => c.includes(`git push -u origin ${SESSION_BRANCH}`))).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("checks out the existing local session branch instead of creating it", async () => {
|
|
275
|
+
const backend = mockWorkspaceBackend({
|
|
276
|
+
"git branch --show-current": "main",
|
|
277
|
+
"git rev-parse --verify --quiet": "deadbeef",
|
|
278
|
+
});
|
|
279
|
+
const coord = makeCoordinator({ sb, backend });
|
|
280
|
+
|
|
281
|
+
await coord.onFileModified("src/main.ts");
|
|
282
|
+
|
|
283
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
284
|
+
.map((c: any) => c[0] as string);
|
|
285
|
+
expect(commands.some((c: string) =>
|
|
286
|
+
c.includes(`git checkout ${SESSION_BRANCH}`) && !c.includes("-b"),
|
|
287
|
+
)).toBe(true);
|
|
288
|
+
expect(commands.some((c: string) => c.includes("git checkout -b"))).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("after a re-provision, fetches and tracks the remote session branch", async () => {
|
|
292
|
+
const backend = mockWorkspaceBackend({
|
|
293
|
+
"git branch --show-current": "main",
|
|
294
|
+
"git rev-parse --verify --quiet": "",
|
|
295
|
+
"git ls-remote --heads origin": `deadbeef\trefs/heads/${SESSION_BRANCH}`,
|
|
296
|
+
});
|
|
297
|
+
const coord = makeCoordinator({ sb, backend });
|
|
298
|
+
|
|
299
|
+
await coord.onFileModified("src/main.ts");
|
|
300
|
+
|
|
301
|
+
const commands = (backend.execute as ReturnType<typeof vi.fn>).mock.calls
|
|
302
|
+
.map((c: any) => c[0] as string);
|
|
303
|
+
expect(commands.some((c: string) => c.includes(`git fetch origin ${SESSION_BRANCH}`))).toBe(true);
|
|
304
|
+
expect(commands.some((c: string) =>
|
|
305
|
+
c.includes(`git checkout -b ${SESSION_BRANCH} origin/${SESSION_BRANCH}`),
|
|
306
|
+
)).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("adopts an already-open PR for the session branch instead of creating a duplicate", async () => {
|
|
310
|
+
const calls = mockGithubApi({
|
|
311
|
+
openPrs: [{ html_url: "https://github.com/acme/my-app/pull/7", number: 7 }],
|
|
312
|
+
});
|
|
313
|
+
const coord = makeCoordinator({ sb });
|
|
314
|
+
|
|
315
|
+
await coord.onFileModified("src/main.ts");
|
|
316
|
+
|
|
317
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
318
|
+
expect(wbs[0].pullRequestNumber).toBe(7);
|
|
319
|
+
expect(wbs[0].pullRequestUrl).toBe("https://github.com/acme/my-app/pull/7");
|
|
320
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED);
|
|
321
|
+
expect(calls.filter((c) => c.method === "POST")).toHaveLength(0);
|
|
322
|
+
const listCall = calls.find((c) => c.method === "GET");
|
|
323
|
+
expect(listCall?.url).toContain("state=open");
|
|
324
|
+
expect(listCall?.url).toContain(encodeURIComponent(`acme:${SESSION_BRANCH}`));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("uses the plumbed token for the GitHub API (never the repo URL or process.env)", async () => {
|
|
328
|
+
const calls = mockGithubApi();
|
|
329
|
+
const coord = makeCoordinator({ sb, githubToken: "ghp_plumbed_token" });
|
|
330
|
+
|
|
331
|
+
await coord.onFileModified("src/main.ts");
|
|
332
|
+
|
|
333
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
334
|
+
for (const call of calls) {
|
|
335
|
+
expect(call.headers["Authorization"]).toBe("Bearer ghp_plumbed_token");
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Failure semantics ───────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
it("sets FAILED phase on a commit/push error", async () => {
|
|
342
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
343
|
+
const backend = mockWorkspaceBackend();
|
|
344
|
+
(backend.execute as ReturnType<typeof vi.fn>).mockImplementation(async (cmd: string) => {
|
|
345
|
+
if (cmd.includes("git diff --stat") && !cmd.includes("...HEAD")) return " changed";
|
|
346
|
+
if (cmd.includes("git diff --cached")) return "";
|
|
347
|
+
if (cmd.includes("git branch --show-current")) return "main";
|
|
348
|
+
if (cmd.includes("commit -m")) throw new Error("commit failed: lock");
|
|
349
|
+
return "";
|
|
350
|
+
});
|
|
351
|
+
const coord = makeCoordinator({ sb, backend });
|
|
352
|
+
|
|
353
|
+
await coord.onFileModified("src/main.ts");
|
|
354
|
+
|
|
355
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
356
|
+
expect(wbs).toHaveLength(1);
|
|
357
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED);
|
|
358
|
+
expect(wbs[0].error).toContain("commit failed");
|
|
359
|
+
warnSpy.mockRestore();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports PUSHED with the PR error when PR creation fails after a successful push", async () => {
|
|
363
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
364
|
+
mockGithubApi({ createStatus: 403 });
|
|
365
|
+
const coord = makeCoordinator({ sb });
|
|
366
|
+
|
|
367
|
+
await coord.onFileModified("src/main.ts");
|
|
368
|
+
|
|
369
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
370
|
+
expect(wbs).toHaveLength(1);
|
|
371
|
+
expect(wbs[0].phase,
|
|
372
|
+
"a pushed branch must never be reported FAILED for a PR-step error",
|
|
373
|
+
).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED);
|
|
374
|
+
expect(wbs[0].branchName).toBe(SESSION_BRANCH);
|
|
375
|
+
expect(wbs[0].commitSha).toBe("abc123def456");
|
|
376
|
+
expect(wbs[0].error).toContain("GitHub API error");
|
|
377
|
+
warnSpy.mockRestore();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("reports PUSHED with an actionable error when no GitHub token is available", async () => {
|
|
381
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
382
|
+
const coord = makeCoordinator({ sb, githubToken: "" });
|
|
383
|
+
|
|
384
|
+
await coord.onFileModified("src/main.ts");
|
|
385
|
+
|
|
386
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
387
|
+
expect(wbs).toHaveLength(1);
|
|
388
|
+
expect(wbs[0].phase).toBe(WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED);
|
|
389
|
+
expect(wbs[0].error).toContain("No GitHub token");
|
|
390
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
391
|
+
warnSpy.mockRestore();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ── finalize / path resolution ──────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
it("finalize catches remaining uncommitted changes", async () => {
|
|
397
|
+
const coord = makeCoordinator({ sb });
|
|
398
|
+
|
|
399
|
+
await coord.finalize();
|
|
400
|
+
|
|
401
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(1);
|
|
402
|
+
expect(sb.currentStatus.workspaceWriteBacks[0].phase).toBe(
|
|
403
|
+
WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED,
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("resolves path to single entry without prefix matching", async () => {
|
|
408
|
+
const coord = makeCoordinator({ sb });
|
|
409
|
+
|
|
410
|
+
await coord.onFileModified("any/path/file.ts");
|
|
411
|
+
|
|
412
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(1);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("resolves path to correct entry in multi-entry workspace", async () => {
|
|
416
|
+
const coord = makeCoordinator({
|
|
417
|
+
sb,
|
|
418
|
+
provisionResults: [
|
|
419
|
+
makeProvisionResult({ entryName: "frontend", rootDir: "/workspace/frontend" }),
|
|
420
|
+
makeProvisionResult({ entryName: "backend", rootDir: "/workspace/backend" }),
|
|
421
|
+
],
|
|
422
|
+
workspaceEntries: [
|
|
423
|
+
makeWorkspaceEntry("frontend"),
|
|
424
|
+
makeWorkspaceEntry("backend"),
|
|
425
|
+
],
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await coord.onFileModified("frontend/src/app.tsx");
|
|
429
|
+
|
|
430
|
+
const wbs = sb.currentStatus.workspaceWriteBacks;
|
|
431
|
+
expect(wbs).toHaveLength(1);
|
|
432
|
+
expect(wbs[0].workspaceEntryName).toBe("frontend");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("ignores paths outside eligible entries in multi-entry mode", async () => {
|
|
436
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
437
|
+
const coord = makeCoordinator({
|
|
438
|
+
sb,
|
|
439
|
+
provisionResults: [
|
|
440
|
+
makeProvisionResult({ entryName: "fe", rootDir: "/workspace/fe" }),
|
|
441
|
+
makeProvisionResult({ entryName: "be", rootDir: "/workspace/be" }),
|
|
442
|
+
],
|
|
443
|
+
workspaceEntries: [makeWorkspaceEntry("fe"), makeWorkspaceEntry("be")],
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await coord.onFileModified("unknown/file.ts");
|
|
447
|
+
expect(sb.currentStatus.workspaceWriteBacks).toHaveLength(0);
|
|
448
|
+
warnSpy.mockRestore();
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe("parseGithubRepo", () => {
|
|
453
|
+
it("parses HTTPS URL with .git suffix", () => {
|
|
454
|
+
const result = parseGithubRepo("https://github.com/acme/my-app.git");
|
|
455
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("parses HTTPS URL without .git suffix", () => {
|
|
459
|
+
const result = parseGithubRepo("https://github.com/acme/my-app");
|
|
460
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("parses HTTPS URL with token", () => {
|
|
464
|
+
const result = parseGithubRepo("https://ghp_token@github.com/acme/my-app.git");
|
|
465
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("parses SSH URL", () => {
|
|
469
|
+
const result = parseGithubRepo("git@github.com:acme/my-app.git");
|
|
470
|
+
expect(result).toEqual({ owner: "acme", repo: "my-app" });
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("throws for non-GitHub URLs", () => {
|
|
474
|
+
expect(() => parseGithubRepo("https://gitlab.com/acme/my-app.git"))
|
|
475
|
+
.toThrow("Cannot parse GitHub owner/repo");
|
|
476
|
+
});
|
|
477
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public git identity for commits the agent authors on the user's behalf.
|
|
3
|
+
*
|
|
4
|
+
* Write-back commits (branch/commit/push/PR — see WriteBackCoordinator) land
|
|
5
|
+
* on real branches in the user's repository, so they carry a dedicated,
|
|
6
|
+
* recognizable agent identity in every mode — local and cloud alike. This is
|
|
7
|
+
* deliberate: a commit authored by the agent should never be silently
|
|
8
|
+
* attributed to whatever identity happens to live in the host's `~/.gitconfig`
|
|
9
|
+
* (local), and the cloud sandbox has no identity at all, which makes a bare
|
|
10
|
+
* `git commit` fail with "Author identity unknown".
|
|
11
|
+
*
|
|
12
|
+
* Distinct from the *internal* snapshot identity in
|
|
13
|
+
* `../filereview/git-substrate.ts` (`stigmer-runner <runner@stigmer.local>`):
|
|
14
|
+
* those commit objects are never on a branch and never pushed — they exist
|
|
15
|
+
* only to pin trees against GC. This identity is the public-facing one that
|
|
16
|
+
* appears in `git log`, on GitHub, and in pull requests.
|
|
17
|
+
*/
|
|
18
|
+
export const AGENT_GIT_AUTHOR_NAME = "Stigmer Agent";
|
|
19
|
+
export const AGENT_GIT_AUTHOR_EMAIL = "noreply@stigmer.ai";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `git -c` config flags that pin the agent identity for a single command.
|
|
23
|
+
*
|
|
24
|
+
* Per-command `-c` flags (rather than repo-local or global `git config`) keep
|
|
25
|
+
* the identity deterministic in every environment without mutating the user's
|
|
26
|
+
* repository config — the same never-mutate reasoning documented on
|
|
27
|
+
* `SNAPSHOT_IDENTITY_ENV` in `git-substrate.ts`. Env-var injection is not an
|
|
28
|
+
* option here: `WorkspaceBackend.execute()` takes no env parameter, and
|
|
29
|
+
* widening that contract for one caller would be a larger change than the
|
|
30
|
+
* problem deserves.
|
|
31
|
+
*/
|
|
32
|
+
export const AGENT_GIT_IDENTITY_FLAGS =
|
|
33
|
+
`-c user.name='${AGENT_GIT_AUTHOR_NAME}' -c user.email='${AGENT_GIT_AUTHOR_EMAIL}'`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a `git commit` command that is always authored by the agent identity,
|
|
37
|
+
* regardless of what identity (if any) the surrounding environment provides.
|
|
38
|
+
*/
|
|
39
|
+
export function gitCommitAsAgent(commitMsg: string): string {
|
|
40
|
+
return `git ${AGENT_GIT_IDENTITY_FLAGS} commit -m "${commitMsg}"`;
|
|
41
|
+
}
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* sandbox) backend will be added in Phase 3.
|
|
7
7
|
*
|
|
8
8
|
* When `platformDir` is provided, paths under `.stigmer/` are
|
|
9
|
-
* transparently routed to the platform directory
|
|
10
|
-
*
|
|
9
|
+
* transparently routed to the platform directory, keeping platform files
|
|
10
|
+
* out of the workspace tree. This routing serves the RUNNER's own I/O
|
|
11
|
+
* (skill/attachment materialization, `execute` command rewriting) — the
|
|
12
|
+
* agent's file tools do not go through this backend. Agent-visible reads
|
|
13
|
+
* of `.stigmer/…` reach the same physical files through the per-turn
|
|
14
|
+
* workspace symlink instead (see shared/workspace/stigmer-link.ts).
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import { execFile } from "node:child_process";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace `.stigmer` symlink — the bridge from the session's workspace
|
|
3
|
+
* to its platform-managed directory (~/.stigmer/sessions/{id}/platform/, see
|
|
4
|
+
* platform-dir.ts), shared by BOTH harnesses.
|
|
5
|
+
*
|
|
6
|
+
* Platform-injected content (skills, attachment inputs, the approved plan)
|
|
7
|
+
* physically lives in the platform dir, outside the workspace, so a real repo
|
|
8
|
+
* is never polluted with Stigmer files (issue #173). But the agent reads from
|
|
9
|
+
* the workspace: the Cursor SDK resolves paths against the workspace CWD, and
|
|
10
|
+
* the native (deepagents) harness's file tools resolve against a
|
|
11
|
+
* `FilesystemBackend` rooted at the workspace. The symlink is what makes the
|
|
12
|
+
* `.stigmer/…` paths those agents are prompted with (skill locations,
|
|
13
|
+
* `.stigmer/inputs/…` attachments) actually resolve.
|
|
14
|
+
*
|
|
15
|
+
* Lifecycle contract (same for both harnesses):
|
|
16
|
+
* - Created per turn, under the workspace turn lock — the link is a
|
|
17
|
+
* working-tree mutation, and re-pointing it while another session's turn is
|
|
18
|
+
* running on a shared tree would redirect that turn's reads mid-flight.
|
|
19
|
+
* Cursor creates it in its skill/attachment resolvers (which run after lock
|
|
20
|
+
* acquisition); the native harness creates it right after acquiring the
|
|
21
|
+
* lock (execute-deep-agent/index.ts).
|
|
22
|
+
* - Removed at turn end ({@link removeStigmerSymlink} in the activity's
|
|
23
|
+
* cleanup), so a real repo is left untouched once the turn ends; a
|
|
24
|
+
* multi-turn session recreates the link on the next turn.
|
|
25
|
+
* - {@link ensureStigmerSymlink} is idempotent, so multiple populaters may
|
|
26
|
+
* run in any order within a turn.
|
|
27
|
+
*
|
|
28
|
+
* OWNERSHIP SHARP EDGE: the platform owns the `.stigmer` name inside a
|
|
29
|
+
* workspace. {@link ensureStigmerSymlink} REPLACES a real (non-symlink)
|
|
30
|
+
* `.stigmer` directory it finds there — deliberate, so a directory left
|
|
31
|
+
* behind by an older runner can never shadow the platform mount. (A user's
|
|
32
|
+
* own `.stigmer` content is unreachable anyway: the runner's
|
|
33
|
+
* `LocalWorkspaceBackend` routes every `.stigmer/…` path to the platform
|
|
34
|
+
* dir.) Turn-end removal is the conservative half: it only ever removes a
|
|
35
|
+
* SYMLINK.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
|
|
39
|
+
import { join } from "node:path";
|
|
40
|
+
|
|
41
|
+
/** The workspace-visible name of the platform namespace. */
|
|
42
|
+
export const STIGMER_LOCAL_STATE_DIR = ".stigmer";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensure the workspace `.stigmer` symlink points to the platform dir.
|
|
46
|
+
* Idempotent: an existing correct link is kept; a stale link (or a non-link
|
|
47
|
+
* left behind by an older runner) is replaced.
|
|
48
|
+
*/
|
|
49
|
+
export async function ensureStigmerSymlink(
|
|
50
|
+
workspaceDir: string,
|
|
51
|
+
platformDir: string,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const existing = await readlink(linkPath);
|
|
57
|
+
if (existing === platformDir) return;
|
|
58
|
+
await unlink(linkPath);
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
if (err.code === "ENOENT") {
|
|
61
|
+
// No existing symlink
|
|
62
|
+
} else if (err.code === "EINVAL") {
|
|
63
|
+
// Exists but is not a symlink — remove the directory
|
|
64
|
+
await rm(linkPath, { recursive: true, force: true });
|
|
65
|
+
} else {
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await symlink(platformDir, linkPath, "dir");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove the workspace `.stigmer` symlink created by
|
|
75
|
+
* {@link ensureStigmerSymlink}.
|
|
76
|
+
*
|
|
77
|
+
* Called in the activity's cleanup so attaching a real repo leaves no Stigmer
|
|
78
|
+
* symlink behind once the turn ends (issue #173). Only ever removes a
|
|
79
|
+
* SYMLINK — a real `.stigmer` directory (which would be the user's own, not
|
|
80
|
+
* ours) is left untouched. Best-effort.
|
|
81
|
+
*/
|
|
82
|
+
export async function removeStigmerSymlink(workspaceDir: string): Promise<void> {
|
|
83
|
+
const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
|
|
84
|
+
try {
|
|
85
|
+
const stat = await lstat(linkPath);
|
|
86
|
+
if (stat.isSymbolicLink()) {
|
|
87
|
+
await unlink(linkPath);
|
|
88
|
+
}
|
|
89
|
+
} catch (err: any) {
|
|
90
|
+
if (err?.code !== "ENOENT") {
|
|
91
|
+
console.warn(
|
|
92
|
+
`removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
|
|
93
|
+
`${err instanceof Error ? err.message : err}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -53,10 +53,12 @@ export class WorkspaceProvisionError extends Error {
|
|
|
53
53
|
* commands route through the Daytona sandbox proxy.
|
|
54
54
|
*
|
|
55
55
|
* When `platformDir` is set, paths under `.stigmer/` are transparently
|
|
56
|
-
* routed to the platform directory instead of the workspace root
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* the agent
|
|
56
|
+
* routed to the platform directory instead of the workspace root, keeping
|
|
57
|
+
* platform files (skills, inputs) physically separate from the user's
|
|
58
|
+
* workspace. The routing covers the RUNNER's own reads/writes through
|
|
59
|
+
* this interface; the agent's file tools resolve against the workspace
|
|
60
|
+
* root and see the same files through the per-turn `.stigmer` symlink
|
|
61
|
+
* (see stigmer-link.ts).
|
|
60
62
|
*/
|
|
61
63
|
export interface WorkspaceBackend {
|
|
62
64
|
readonly rootDir: string;
|