@markjaquith/agency 2.54.3 → 2.55.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 +31 -1
- package/cli-main.ts +20 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +9 -0
- package/src/cli-parser.ts +10 -0
- package/src/cli.test.ts +2 -1
- package/src/commands/push.ts +25 -0
- package/src/commands/work.test.ts +23 -9
- package/src/commands/work.ts +1 -0
- package/src/protocol.ts +1 -0
- package/src/services/IntegrationService.test.ts +152 -0
- package/src/services/PushService.test.ts +516 -0
- package/src/services/PushService.ts +576 -0
- package/src/test-utils.ts +2 -0
- package/src/workbase/AGENTS.md +27 -0
- package/src/workbase/opencode-plugin-file.ts +130 -45
- package/src/workbase/runner-command.test.ts +3 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { ContextService } from "./ContextService"
|
|
3
|
+
import { FileSystemService } from "./FileSystemService"
|
|
4
|
+
import { PhaseService } from "./PhaseService"
|
|
5
|
+
import { TaskService } from "./TaskService"
|
|
6
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
7
|
+
|
|
8
|
+
class PushError extends Data.TaggedError("PushError")<{
|
|
9
|
+
readonly message: string
|
|
10
|
+
}> {}
|
|
11
|
+
|
|
12
|
+
interface CommandResult {
|
|
13
|
+
readonly exitCode: number
|
|
14
|
+
readonly stdout: string
|
|
15
|
+
readonly stderr: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CommitMetadata {
|
|
19
|
+
readonly commitId: string
|
|
20
|
+
readonly changeId?: string
|
|
21
|
+
readonly description: string
|
|
22
|
+
readonly empty: boolean
|
|
23
|
+
readonly authorName: string
|
|
24
|
+
readonly authorEmail: string
|
|
25
|
+
readonly conflict: boolean
|
|
26
|
+
readonly parents: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PushResult {
|
|
30
|
+
readonly vcs: "git" | "jj"
|
|
31
|
+
readonly taskId: string
|
|
32
|
+
readonly phaseId?: string
|
|
33
|
+
readonly branch: string
|
|
34
|
+
readonly base: string
|
|
35
|
+
readonly remote: string
|
|
36
|
+
readonly tip: string
|
|
37
|
+
readonly changeId?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
|
|
41
|
+
|
|
42
|
+
const requireCommand = (
|
|
43
|
+
fs: FileSystemService,
|
|
44
|
+
args: readonly string[],
|
|
45
|
+
cwd: string,
|
|
46
|
+
label: string,
|
|
47
|
+
) =>
|
|
48
|
+
fs.runCommand(args, { cwd, captureOutput: true }).pipe(
|
|
49
|
+
Effect.flatMap((result) =>
|
|
50
|
+
result.exitCode === 0
|
|
51
|
+
? Effect.succeed(result)
|
|
52
|
+
: Effect.fail(
|
|
53
|
+
new PushError({
|
|
54
|
+
message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const git = (
|
|
61
|
+
fs: FileSystemService,
|
|
62
|
+
cwd: string,
|
|
63
|
+
args: readonly string[],
|
|
64
|
+
label: string,
|
|
65
|
+
) => requireCommand(fs, ["git", ...args], cwd, label)
|
|
66
|
+
|
|
67
|
+
const gitRevision = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
68
|
+
fs
|
|
69
|
+
.runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
|
|
70
|
+
cwd,
|
|
71
|
+
captureOutput: true,
|
|
72
|
+
})
|
|
73
|
+
.pipe(
|
|
74
|
+
Effect.map((result) =>
|
|
75
|
+
result.exitCode === 0 ? result.stdout.trim() || null : null,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const gitAncestor = (
|
|
80
|
+
fs: FileSystemService,
|
|
81
|
+
cwd: string,
|
|
82
|
+
ancestor: string,
|
|
83
|
+
descendant: string,
|
|
84
|
+
) =>
|
|
85
|
+
fs
|
|
86
|
+
.runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
|
|
87
|
+
cwd,
|
|
88
|
+
captureOutput: true,
|
|
89
|
+
})
|
|
90
|
+
.pipe(
|
|
91
|
+
Effect.flatMap((result) => {
|
|
92
|
+
if (result.exitCode === 0) return Effect.succeed(true)
|
|
93
|
+
if (result.exitCode === 1) return Effect.succeed(false)
|
|
94
|
+
return Effect.fail(
|
|
95
|
+
new PushError({
|
|
96
|
+
message: `Failed to inspect Git ancestry: ${result.stderr.trim()}`,
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const parseGitCommits = (output: string): readonly CommitMetadata[] =>
|
|
103
|
+
output
|
|
104
|
+
.split("\x1e")
|
|
105
|
+
.map((record) => record.replace(/^\n+|\n+$/g, ""))
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((record) => {
|
|
108
|
+
const [
|
|
109
|
+
commitId = "",
|
|
110
|
+
authorName = "",
|
|
111
|
+
authorEmail = "",
|
|
112
|
+
description = "",
|
|
113
|
+
] = record.split("\0")
|
|
114
|
+
return {
|
|
115
|
+
commitId,
|
|
116
|
+
description,
|
|
117
|
+
empty: false,
|
|
118
|
+
authorName,
|
|
119
|
+
authorEmail,
|
|
120
|
+
conflict: false,
|
|
121
|
+
parents: [],
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const validateGitCommits = (
|
|
126
|
+
commits: readonly CommitMetadata[],
|
|
127
|
+
base: string,
|
|
128
|
+
) => {
|
|
129
|
+
if (commits.length === 0) {
|
|
130
|
+
throw new PushError({
|
|
131
|
+
message: `No commits to publish after base '${base}'`,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
const issues: string[] = []
|
|
135
|
+
for (const commit of commits) {
|
|
136
|
+
if (!commit.description.trim()) {
|
|
137
|
+
issues.push(
|
|
138
|
+
`Commit ${commit.commitId} has an empty message. Run: git rebase -i ${base}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
|
|
142
|
+
issues.push(
|
|
143
|
+
`Commit ${commit.commitId} has an invalid author. Run: git rebase -i ${base}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const publishGit = (
|
|
151
|
+
fs: FileSystemService,
|
|
152
|
+
checkout: string,
|
|
153
|
+
remote: string,
|
|
154
|
+
branch: string,
|
|
155
|
+
base: string,
|
|
156
|
+
) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const currentBranch = yield* git(
|
|
159
|
+
fs,
|
|
160
|
+
checkout,
|
|
161
|
+
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
162
|
+
"Git checkout must be attached to the declared branch",
|
|
163
|
+
)
|
|
164
|
+
if (currentBranch.stdout.trim() !== branch) {
|
|
165
|
+
return yield* new PushError({
|
|
166
|
+
message: `Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
const status = yield* git(
|
|
170
|
+
fs,
|
|
171
|
+
checkout,
|
|
172
|
+
["status", "--porcelain=v1"],
|
|
173
|
+
"Failed to inspect Git status",
|
|
174
|
+
)
|
|
175
|
+
if (status.stdout.length > 0) {
|
|
176
|
+
return yield* new PushError({
|
|
177
|
+
message:
|
|
178
|
+
"Cannot publish a dirty Git worktree; commit or discard changes first",
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
yield* git(
|
|
183
|
+
fs,
|
|
184
|
+
checkout,
|
|
185
|
+
["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
|
|
186
|
+
`Failed to fetch remote '${remote}'`,
|
|
187
|
+
)
|
|
188
|
+
const tip = yield* gitRevision(fs, checkout, "HEAD")
|
|
189
|
+
const baseRevision = yield* gitRevision(
|
|
190
|
+
fs,
|
|
191
|
+
checkout,
|
|
192
|
+
`refs/remotes/${remote}/${base}`,
|
|
193
|
+
)
|
|
194
|
+
if (!tip || !baseRevision) {
|
|
195
|
+
return yield* new PushError({
|
|
196
|
+
message: `Declared base '${base}' was not found on remote '${remote}'`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
|
|
200
|
+
return yield* new PushError({
|
|
201
|
+
message: `Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const log = yield* git(
|
|
206
|
+
fs,
|
|
207
|
+
checkout,
|
|
208
|
+
[
|
|
209
|
+
"log",
|
|
210
|
+
"--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
|
|
211
|
+
`${baseRevision}..${tip}`,
|
|
212
|
+
],
|
|
213
|
+
"Failed to inspect outgoing Git commits",
|
|
214
|
+
)
|
|
215
|
+
yield* Effect.try({
|
|
216
|
+
try: () => validateGitCommits(parseGitCommits(log.stdout), base),
|
|
217
|
+
catch: (cause) => cause as PushError,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const remoteTip = yield* gitRevision(
|
|
221
|
+
fs,
|
|
222
|
+
checkout,
|
|
223
|
+
`refs/remotes/${remote}/${branch}`,
|
|
224
|
+
)
|
|
225
|
+
if (remoteTip && !(yield* gitAncestor(fs, checkout, remoteTip, tip))) {
|
|
226
|
+
return yield* new PushError({
|
|
227
|
+
message: `Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
yield* git(
|
|
232
|
+
fs,
|
|
233
|
+
checkout,
|
|
234
|
+
["push", remote, `HEAD:refs/heads/${branch}`],
|
|
235
|
+
`Failed to push declared branch '${branch}'`,
|
|
236
|
+
)
|
|
237
|
+
yield* git(
|
|
238
|
+
fs,
|
|
239
|
+
checkout,
|
|
240
|
+
[
|
|
241
|
+
"config",
|
|
242
|
+
`remote.${remote}.fetch`,
|
|
243
|
+
`+refs/heads/*:refs/remotes/${remote}/*`,
|
|
244
|
+
],
|
|
245
|
+
`Failed to configure remote '${remote}' tracking`,
|
|
246
|
+
)
|
|
247
|
+
yield* git(
|
|
248
|
+
fs,
|
|
249
|
+
checkout,
|
|
250
|
+
[
|
|
251
|
+
"fetch",
|
|
252
|
+
remote,
|
|
253
|
+
`+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
|
|
254
|
+
],
|
|
255
|
+
`Failed to refresh published branch '${branch}'`,
|
|
256
|
+
)
|
|
257
|
+
yield* git(
|
|
258
|
+
fs,
|
|
259
|
+
checkout,
|
|
260
|
+
["branch", "--set-upstream-to", `${remote}/${branch}`, branch],
|
|
261
|
+
`Failed to establish upstream tracking for branch '${branch}'`,
|
|
262
|
+
)
|
|
263
|
+
return { tip }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const jjTemplate =
|
|
267
|
+
'"{\\"commitId\\":" ++ json(commit_id) ++ ",\\"changeId\\":" ++ json(change_id) ++ ",\\"description\\":" ++ json(description) ++ ",\\"empty\\":" ++ json(empty) ++ ",\\"authorName\\":" ++ json(author.name()) ++ ",\\"authorEmail\\":" ++ json(author.email()) ++ ",\\"conflict\\":" ++ json(conflict) ++ ",\\"parents\\":" ++ json(parents.map(|parent| parent.commit_id())) ++ "}\\n"'
|
|
268
|
+
|
|
269
|
+
const jjExact = (value: string) =>
|
|
270
|
+
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
|
|
271
|
+
|
|
272
|
+
const jj = (
|
|
273
|
+
fs: FileSystemService,
|
|
274
|
+
cwd: string,
|
|
275
|
+
args: readonly string[],
|
|
276
|
+
label: string,
|
|
277
|
+
) => requireCommand(fs, ["jj", "--no-pager", ...args], cwd, label)
|
|
278
|
+
|
|
279
|
+
const jjCommits = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
280
|
+
jj(
|
|
281
|
+
fs,
|
|
282
|
+
cwd,
|
|
283
|
+
["log", "--no-graph", "-r", revision, "-T", jjTemplate],
|
|
284
|
+
"Failed to inspect jj changes",
|
|
285
|
+
).pipe(
|
|
286
|
+
Effect.map((result) =>
|
|
287
|
+
result.stdout
|
|
288
|
+
.split("\n")
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.map((line) => JSON.parse(line) as CommitMetadata),
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const jjRevision = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
295
|
+
jjCommits(fs, cwd, revision).pipe(
|
|
296
|
+
Effect.map((commits) => {
|
|
297
|
+
if (commits.length === 0) return null
|
|
298
|
+
if (commits.length === 1) return commits[0]!
|
|
299
|
+
throw new PushError({
|
|
300
|
+
message: `Revision '${revision}' resolves to multiple jj commits`,
|
|
301
|
+
})
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const optionalJjRevision = (
|
|
306
|
+
fs: FileSystemService,
|
|
307
|
+
cwd: string,
|
|
308
|
+
revision: string,
|
|
309
|
+
) =>
|
|
310
|
+
jjCommits(fs, cwd, revision).pipe(
|
|
311
|
+
Effect.catchTag("PushError", () => Effect.succeed([])),
|
|
312
|
+
Effect.map((commits) => {
|
|
313
|
+
if (commits.length === 0) return null
|
|
314
|
+
if (commits.length === 1) return commits[0]!
|
|
315
|
+
throw new PushError({
|
|
316
|
+
message: `Bookmark '${revision}' is conflicted or resolves to multiple commits`,
|
|
317
|
+
})
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const jjAncestor = (
|
|
322
|
+
fs: FileSystemService,
|
|
323
|
+
cwd: string,
|
|
324
|
+
ancestor: string,
|
|
325
|
+
descendant: string,
|
|
326
|
+
) =>
|
|
327
|
+
jjCommits(fs, cwd, `${ancestor} & ::${descendant}`).pipe(
|
|
328
|
+
Effect.map((commits) => commits.length === 1),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const validateJjCommits = (
|
|
332
|
+
commits: readonly CommitMetadata[],
|
|
333
|
+
base: string,
|
|
334
|
+
) => {
|
|
335
|
+
if (commits.length === 0) {
|
|
336
|
+
throw new PushError({
|
|
337
|
+
message: `No changes to publish after base '${base}'`,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
const issues: string[] = []
|
|
341
|
+
for (const commit of commits) {
|
|
342
|
+
const id = commit.changeId!
|
|
343
|
+
if (commit.conflict) {
|
|
344
|
+
issues.push(`Change ${id} contains conflicts. Run: jj resolve -r ${id}`)
|
|
345
|
+
}
|
|
346
|
+
if (!commit.description.trim()) {
|
|
347
|
+
issues.push(`Change ${id} has no description. Run: jj describe -r ${id}`)
|
|
348
|
+
}
|
|
349
|
+
if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
|
|
350
|
+
issues.push(
|
|
351
|
+
`Change ${id} has an invalid author. Run: jj metaedit -r ${id} --author 'Name <email>'`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const publishJj = (
|
|
359
|
+
fs: FileSystemService,
|
|
360
|
+
checkout: string,
|
|
361
|
+
remote: string,
|
|
362
|
+
branch: string,
|
|
363
|
+
base: string,
|
|
364
|
+
) =>
|
|
365
|
+
Effect.gen(function* () {
|
|
366
|
+
yield* jj(
|
|
367
|
+
fs,
|
|
368
|
+
checkout,
|
|
369
|
+
["git", "fetch", "--remote", remote],
|
|
370
|
+
`Failed to fetch remote '${remote}'`,
|
|
371
|
+
)
|
|
372
|
+
const workingCopy = yield* jjRevision(fs, checkout, "@")
|
|
373
|
+
if (!workingCopy) {
|
|
374
|
+
return yield* new PushError({
|
|
375
|
+
message: "jj working copy commit was not found",
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
const canonicalPostCommit =
|
|
379
|
+
workingCopy.empty &&
|
|
380
|
+
!workingCopy.description.trim() &&
|
|
381
|
+
workingCopy.parents.length === 1
|
|
382
|
+
const tip = canonicalPostCommit
|
|
383
|
+
? yield* jjRevision(fs, checkout, "@-")
|
|
384
|
+
: workingCopy
|
|
385
|
+
if (!tip) {
|
|
386
|
+
return yield* new PushError({
|
|
387
|
+
message: "jj publication tip was not found",
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const baseBookmark = `${base}@${remote}`
|
|
392
|
+
const baseRevision = yield* optionalJjRevision(
|
|
393
|
+
fs,
|
|
394
|
+
checkout,
|
|
395
|
+
`remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
|
|
396
|
+
)
|
|
397
|
+
if (!baseRevision) {
|
|
398
|
+
return yield* new PushError({
|
|
399
|
+
message: `Declared base '${base}' was not found on remote '${remote}'`,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
if (
|
|
403
|
+
!(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
|
|
404
|
+
) {
|
|
405
|
+
return yield* new PushError({
|
|
406
|
+
message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const outgoing = yield* jjCommits(
|
|
411
|
+
fs,
|
|
412
|
+
checkout,
|
|
413
|
+
`${baseRevision.commitId}..${tip.commitId}`,
|
|
414
|
+
)
|
|
415
|
+
yield* Effect.try({
|
|
416
|
+
try: () => validateJjCommits(outgoing, base),
|
|
417
|
+
catch: (cause) => cause as PushError,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const localBookmark = yield* optionalJjRevision(
|
|
421
|
+
fs,
|
|
422
|
+
checkout,
|
|
423
|
+
`bookmarks(exact:"${jjExact(branch)}")`,
|
|
424
|
+
)
|
|
425
|
+
if (
|
|
426
|
+
localBookmark &&
|
|
427
|
+
!(yield* jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId))
|
|
428
|
+
) {
|
|
429
|
+
return yield* new PushError({
|
|
430
|
+
message: `Local bookmark '${branch}' is not an ancestor of jj tip ${tip.changeId}; refusing to move it`,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
const remoteBookmark = yield* optionalJjRevision(
|
|
434
|
+
fs,
|
|
435
|
+
checkout,
|
|
436
|
+
`remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
|
|
437
|
+
)
|
|
438
|
+
if (
|
|
439
|
+
remoteBookmark &&
|
|
440
|
+
!(yield* jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId))
|
|
441
|
+
) {
|
|
442
|
+
return yield* new PushError({
|
|
443
|
+
message: `Remote bookmark '${branch}@${remote}' is not an ancestor of jj tip ${tip.changeId}; refusing a non-fast-forward update`,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
yield* jj(
|
|
448
|
+
fs,
|
|
449
|
+
checkout,
|
|
450
|
+
["bookmark", "set", branch, "-r", tip.commitId],
|
|
451
|
+
`Failed to set declared bookmark '${branch}'`,
|
|
452
|
+
)
|
|
453
|
+
yield* jj(
|
|
454
|
+
fs,
|
|
455
|
+
checkout,
|
|
456
|
+
["git", "push", "--remote", remote, "--bookmark", branch],
|
|
457
|
+
`Failed to push declared bookmark '${branch}'`,
|
|
458
|
+
)
|
|
459
|
+
yield* jj(
|
|
460
|
+
fs,
|
|
461
|
+
checkout,
|
|
462
|
+
["bookmark", "track", `${branch}@${remote}`],
|
|
463
|
+
`Failed to track remote bookmark '${branch}@${remote}'`,
|
|
464
|
+
)
|
|
465
|
+
return { tip: tip.commitId, changeId: tip.changeId }
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
469
|
+
sync: () => ({
|
|
470
|
+
publish: (startPath: string = process.cwd()) =>
|
|
471
|
+
Effect.gen(function* () {
|
|
472
|
+
const contexts = yield* ContextService
|
|
473
|
+
const fs = yield* FileSystemService
|
|
474
|
+
const tasks = yield* TaskService
|
|
475
|
+
const phases = yield* PhaseService
|
|
476
|
+
const workbase = yield* WorkbaseService
|
|
477
|
+
const context = yield* contexts.get({
|
|
478
|
+
cwd: startPath,
|
|
479
|
+
target: ".",
|
|
480
|
+
compact: true,
|
|
481
|
+
})
|
|
482
|
+
if (!context.validation.valid) {
|
|
483
|
+
return yield* new PushError({
|
|
484
|
+
message: "Cannot publish from an invalid Agency workbase",
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
if (context.target.kind !== "task" && context.target.kind !== "phase") {
|
|
488
|
+
return yield* new PushError({
|
|
489
|
+
message: "agency push must run from an execution task or phase",
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
if (
|
|
493
|
+
context.authority.mode !== "execution" ||
|
|
494
|
+
!context.authority.writable
|
|
495
|
+
) {
|
|
496
|
+
return yield* new PushError({
|
|
497
|
+
message:
|
|
498
|
+
"Current Agency target has no writable execution authority",
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
!context.workspace?.writable?.materialized ||
|
|
503
|
+
!context.workspace.writable.registered
|
|
504
|
+
) {
|
|
505
|
+
return yield* new PushError({
|
|
506
|
+
message:
|
|
507
|
+
"Current Agency writable checkout is not materialized and registered",
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
const blockers = context.graph.readiness.blockers.filter(
|
|
511
|
+
(blocker) =>
|
|
512
|
+
blocker.kind === "dependency" || blocker.kind === "validation",
|
|
513
|
+
)
|
|
514
|
+
if (blockers.length > 0) {
|
|
515
|
+
return yield* new PushError({
|
|
516
|
+
message: `Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const taskId = context.target.taskId
|
|
521
|
+
if (!taskId) {
|
|
522
|
+
return yield* new PushError({
|
|
523
|
+
message: "Current Agency execution target has no task ID",
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
const phaseId =
|
|
527
|
+
context.target.kind === "phase" ? context.target.phaseId : undefined
|
|
528
|
+
const task = yield* tasks.show(taskId, context.workbase.root)
|
|
529
|
+
const execution =
|
|
530
|
+
"phases" in task.data
|
|
531
|
+
? phaseId
|
|
532
|
+
? (yield* phases.show(taskId, phaseId, context.workbase.root))
|
|
533
|
+
.data
|
|
534
|
+
: null
|
|
535
|
+
: task.data
|
|
536
|
+
if (!execution || "review" in execution) {
|
|
537
|
+
return yield* new PushError({
|
|
538
|
+
message: "Current Agency target is not a delivery execution unit",
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
if (execution.status !== "working") {
|
|
542
|
+
return yield* new PushError({
|
|
543
|
+
message: `Cannot publish Agency work with status '${execution.status}'; status must be working`,
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
const checkout = context.authority.writable.checkoutPath
|
|
547
|
+
const { config } = yield* workbase.loadConfig(context.workbase.root)
|
|
548
|
+
const remote = config.delivery?.remote ?? "origin"
|
|
549
|
+
const published =
|
|
550
|
+
context.workbase.vcs === "jj"
|
|
551
|
+
? yield* publishJj(
|
|
552
|
+
fs,
|
|
553
|
+
checkout,
|
|
554
|
+
remote,
|
|
555
|
+
execution.branch,
|
|
556
|
+
execution.base,
|
|
557
|
+
)
|
|
558
|
+
: yield* publishGit(
|
|
559
|
+
fs,
|
|
560
|
+
checkout,
|
|
561
|
+
remote,
|
|
562
|
+
execution.branch,
|
|
563
|
+
execution.base,
|
|
564
|
+
)
|
|
565
|
+
return {
|
|
566
|
+
vcs: context.workbase.vcs,
|
|
567
|
+
taskId,
|
|
568
|
+
...(phaseId ? { phaseId } : {}),
|
|
569
|
+
branch: execution.branch,
|
|
570
|
+
base: execution.base,
|
|
571
|
+
remote,
|
|
572
|
+
...published,
|
|
573
|
+
} satisfies PushResult
|
|
574
|
+
}),
|
|
575
|
+
}),
|
|
576
|
+
}) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { TaskService } from "./services/TaskService"
|
|
|
11
11
|
import { PhaseService } from "./services/PhaseService"
|
|
12
12
|
import { WorktreeService } from "./services/WorktreeService"
|
|
13
13
|
import { PullRequestService } from "./services/PullRequestService"
|
|
14
|
+
import { PushService } from "./services/PushService"
|
|
14
15
|
import { ArchiveService } from "./services/ArchiveService"
|
|
15
16
|
import { IntegrationService } from "./services/IntegrationService"
|
|
16
17
|
import { ContextService } from "./services/ContextService"
|
|
@@ -46,6 +47,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
46
47
|
PhaseService.Default,
|
|
47
48
|
WorktreeService.Default,
|
|
48
49
|
PullRequestService.Default,
|
|
50
|
+
PushService.Default,
|
|
49
51
|
ArchiveService.Default,
|
|
50
52
|
IntegrationService.Default,
|
|
51
53
|
ContextService.Default,
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -69,6 +69,11 @@ change only the writable checkout, keep durable decisions current, and run the
|
|
|
69
69
|
repository's formatting, type checks, build, dead-code checks, and focused tests.
|
|
70
70
|
Review and commit the diff according to the repository's instructions.
|
|
71
71
|
|
|
72
|
+
Use `agency push` from an execution task or phase to validate and publish its
|
|
73
|
+
declared delivery branch or bookmark without creating a pull request. The
|
|
74
|
+
command never authors semantic commit descriptions; resolve its reported change
|
|
75
|
+
IDs and remediation commands before retrying.
|
|
76
|
+
|
|
72
77
|
`agency work` is the human launch flow: it reconciles managed integration,
|
|
73
78
|
selects work, checks readiness, prepares checkouts, marks execution work
|
|
74
79
|
`working` without creating a claim, and starts the runner. Epic and multi-phase
|
|
@@ -76,6 +81,28 @@ task launches remain orchestration-only. External orchestrators instead claim
|
|
|
76
81
|
an execution unit, launch and monitor their runner separately, and finish or
|
|
77
82
|
release the claim with the current document revision.
|
|
78
83
|
|
|
84
|
+
An Agency-launched runner receives process-local worker identity through both
|
|
85
|
+
the `AGENCY_SESSION_ID` and `AGENCY_TARGET` environment variables and a generated
|
|
86
|
+
prompt beginning `Agency worker launch target: <target>.` Treat either form as
|
|
87
|
+
launch evidence only after `agency context . --json` confirms the same target,
|
|
88
|
+
document paths, valid context, and expected write authority. Once confirmed,
|
|
89
|
+
perform the assigned work directly and never invoke `agency work` to start the
|
|
90
|
+
same target again.
|
|
91
|
+
|
|
92
|
+
Some runner clients attach to a long-lived process and may not preserve launch
|
|
93
|
+
environment variables. If the variables and prompt marker are absent, fail safe
|
|
94
|
+
when the initial instruction is a generated `Start`, `Continue`, or `Work on`
|
|
95
|
+
prompt whose absolute document paths match the current directory and the active,
|
|
96
|
+
valid `agency context`: treat the process as the current worker and do not
|
|
97
|
+
recursively launch. Herdr state is never part of worker identity. If the prompt
|
|
98
|
+
and context disagree, stop and ask the user rather than launching.
|
|
99
|
+
|
|
100
|
+
For OpenCode, Agency's managed plugin validates the generated marker against
|
|
101
|
+
`agency context`, binds that identity to the OpenCode session, injects an
|
|
102
|
+
active-worker system instruction, and supplies Agency identity to that session's
|
|
103
|
+
shell environment. This avoids relying on the environment of OpenCode's
|
|
104
|
+
long-lived server process.
|
|
105
|
+
|
|
79
106
|
## Closeout
|
|
80
107
|
|
|
81
108
|
An execution unit remains `working` after implementation is committed and while
|