@markjaquith/agency 3.2.1 → 3.2.2
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 +10 -2
- package/cli-main.ts +38 -0
- package/package.json +1 -1
- package/src/commands/push.test.ts +4 -2
- package/src/commands/push.ts +2 -0
- package/src/protocol.test.ts +24 -0
- package/src/protocol.ts +56 -4
- package/src/services/FileSystemService.ts +2 -0
- package/src/services/PushService.test.ts +110 -4
- package/src/services/PushService.ts +435 -85
- package/src/usage-log.test.ts +11 -1
- package/src/usage-log.ts +22 -4
- package/src/utils/process.test.ts +10 -0
- package/src/utils/process.ts +59 -6
package/README.md
CHANGED
|
@@ -1107,13 +1107,21 @@ without creating a pull request. It requires a valid, registered writable
|
|
|
1107
1107
|
checkout in `working` state, fetches the configured delivery remote, verifies
|
|
1108
1108
|
that the declared base is in the publication history, validates every outgoing
|
|
1109
1109
|
commit's description, author, and conflict state, and refuses non-fast-forward
|
|
1110
|
-
updates.
|
|
1110
|
+
updates. Local commit validation runs before network access when cached remote
|
|
1111
|
+
state is available, and the network fetch is limited to the declared base and
|
|
1112
|
+
delivery branch.
|
|
1111
1113
|
|
|
1112
1114
|
YAML `branch` must exactly match the checked-out local branch, the worktree must
|
|
1113
1115
|
be clean, and `HEAD` is pushed with upstream tracking. Missing commit descriptions
|
|
1114
1116
|
or authors stop publication with exact commits and remediation commands. Push
|
|
1115
1117
|
reports deterministic fetch, inspection, validation, and publication progress on
|
|
1116
|
-
stderr, including while `--json` reserves stdout for one machine result.
|
|
1118
|
+
stderr, including while `--json` reserves stdout for one machine result. Git
|
|
1119
|
+
authentication is non-interactive. Fetch defaults to a 30-second deadline and
|
|
1120
|
+
one retry for transient failures; push defaults to a 120-second deadline. Set
|
|
1121
|
+
`AGENCY_PUSH_FETCH_TIMEOUT_MS` or `AGENCY_PUSH_TIMEOUT_MS` to positive millisecond
|
|
1122
|
+
values to override them. After a failed or timed-out push, Agency compares the
|
|
1123
|
+
exact remote delivery ref with the expected tip before reporting success, a safe
|
|
1124
|
+
retryable failure, or an unknown publication outcome.
|
|
1117
1125
|
|
|
1118
1126
|
Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
|
|
1119
1127
|
including readiness checks and durable PR recording. It accepts draft, title,
|
package/cli-main.ts
CHANGED
|
@@ -828,6 +828,42 @@ let usageFlagNames = rawArguments
|
|
|
828
828
|
.filter((argument) => argument.startsWith("--"))
|
|
829
829
|
.map((argument) => argument.slice(2).split("=", 1)[0]!)
|
|
830
830
|
|
|
831
|
+
const pushUsageDetails = (error?: unknown) => {
|
|
832
|
+
if (usageCommandPath !== "push") return {}
|
|
833
|
+
const seen = new Set<object>()
|
|
834
|
+
const visit = (
|
|
835
|
+
value: unknown,
|
|
836
|
+
): { stage?: string; category?: string } | null => {
|
|
837
|
+
if (typeof value !== "object" || value === null || seen.has(value))
|
|
838
|
+
return null
|
|
839
|
+
seen.add(value)
|
|
840
|
+
if (
|
|
841
|
+
"stage" in value &&
|
|
842
|
+
typeof value.stage === "string" &&
|
|
843
|
+
"category" in value &&
|
|
844
|
+
typeof value.category === "string"
|
|
845
|
+
) {
|
|
846
|
+
return { stage: value.stage, category: value.category }
|
|
847
|
+
}
|
|
848
|
+
for (const nestedValue of [
|
|
849
|
+
...Object.values(value),
|
|
850
|
+
...Object.getOwnPropertySymbols(value).map(
|
|
851
|
+
(symbol) => (value as Record<symbol, unknown>)[symbol],
|
|
852
|
+
),
|
|
853
|
+
]) {
|
|
854
|
+
const nested = visit(nestedValue)
|
|
855
|
+
if (nested) return nested
|
|
856
|
+
}
|
|
857
|
+
return null
|
|
858
|
+
}
|
|
859
|
+
const details = error ? visit(error) : null
|
|
860
|
+
return {
|
|
861
|
+
vcs: "git" as const,
|
|
862
|
+
terminalStage: details?.stage ?? (error ? "unknown" : "publish"),
|
|
863
|
+
category: details?.category ?? (error ? "unknown" : "success"),
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
831
867
|
try {
|
|
832
868
|
const {
|
|
833
869
|
commandName,
|
|
@@ -921,6 +957,7 @@ try {
|
|
|
921
957
|
durationMs: performance.now() - invocationStartedAt,
|
|
922
958
|
outcome: exitStatus === 0 ? "success" : "failure",
|
|
923
959
|
exitStatus,
|
|
960
|
+
...pushUsageDetails(),
|
|
924
961
|
},
|
|
925
962
|
VERSION,
|
|
926
963
|
)
|
|
@@ -932,6 +969,7 @@ try {
|
|
|
932
969
|
durationMs: performance.now() - invocationStartedAt,
|
|
933
970
|
outcome: "failure",
|
|
934
971
|
exitStatus: 1,
|
|
972
|
+
...pushUsageDetails(error),
|
|
935
973
|
},
|
|
936
974
|
VERSION,
|
|
937
975
|
)
|
package/package.json
CHANGED
|
@@ -28,9 +28,10 @@ describe("push command", () => {
|
|
|
28
28
|
publish: (_cwd: string, options: any) => {
|
|
29
29
|
for (const stage of [
|
|
30
30
|
"context",
|
|
31
|
-
"fetch",
|
|
32
31
|
"inspect",
|
|
33
32
|
"validate",
|
|
33
|
+
"fetch",
|
|
34
|
+
"validate",
|
|
34
35
|
"publish",
|
|
35
36
|
] as const)
|
|
36
37
|
options.onProgress(stage)
|
|
@@ -44,9 +45,10 @@ describe("push command", () => {
|
|
|
44
45
|
expect(logs).toEqual([JSON.stringify(result, null, 2)])
|
|
45
46
|
expect(updates).toEqual([
|
|
46
47
|
"start:Inspecting Agency execution context",
|
|
47
|
-
"start:Fetching remote state",
|
|
48
48
|
"start:Selecting the publication tip",
|
|
49
49
|
"start:Validating outgoing changes",
|
|
50
|
+
"start:Fetching remote state",
|
|
51
|
+
"start:Validating outgoing changes",
|
|
50
52
|
"start:Publishing the declared branch",
|
|
51
53
|
"succeed:Published task/example to origin",
|
|
52
54
|
])
|
package/src/commands/push.ts
CHANGED
|
@@ -10,6 +10,7 @@ const stageMessage = {
|
|
|
10
10
|
inspect: "Selecting the publication tip",
|
|
11
11
|
validate: "Validating outgoing changes",
|
|
12
12
|
publish: "Publishing the declared branch",
|
|
13
|
+
reconcile: "Confirming the remote publication outcome",
|
|
13
14
|
} as const
|
|
14
15
|
|
|
15
16
|
export const push = (
|
|
@@ -22,6 +23,7 @@ export const push = (
|
|
|
22
23
|
const showProgress = !options.silent
|
|
23
24
|
const result = yield* publications
|
|
24
25
|
.publish(options.cwd ?? process.cwd(), {
|
|
26
|
+
forwardOutput: options.verbose,
|
|
25
27
|
onProgress: showProgress
|
|
26
28
|
? (stage) => progress.start(stageMessage[stage])
|
|
27
29
|
: undefined,
|
package/src/protocol.test.ts
CHANGED
|
@@ -161,4 +161,28 @@ describe("machine protocol", () => {
|
|
|
161
161
|
},
|
|
162
162
|
})
|
|
163
163
|
})
|
|
164
|
+
|
|
165
|
+
test("uses dynamic protocol metadata without duplicating it in fields", () => {
|
|
166
|
+
expect(
|
|
167
|
+
errorEnvelope({
|
|
168
|
+
_tag: "PushError",
|
|
169
|
+
message: "remote timed out",
|
|
170
|
+
protocolCode: "PUSH_TIMEOUT",
|
|
171
|
+
retryable: true,
|
|
172
|
+
remediation: "Retry after checking connectivity.",
|
|
173
|
+
category: "timeout",
|
|
174
|
+
stage: "fetch",
|
|
175
|
+
}),
|
|
176
|
+
).toEqual({
|
|
177
|
+
version: 1,
|
|
178
|
+
ok: false,
|
|
179
|
+
error: {
|
|
180
|
+
code: "PUSH_TIMEOUT",
|
|
181
|
+
message: "remote timed out",
|
|
182
|
+
fields: { category: "timeout", stage: "fetch" },
|
|
183
|
+
retryable: true,
|
|
184
|
+
remediation: "Retry after checking connectivity.",
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
})
|
|
164
188
|
})
|
package/src/protocol.ts
CHANGED
|
@@ -198,6 +198,36 @@ const errorTag = (error: unknown): string | undefined => {
|
|
|
198
198
|
return undefined
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
const unwrapEffectError = (error: unknown): unknown => {
|
|
202
|
+
const seen = new Set<object>()
|
|
203
|
+
const visit = (value: unknown): unknown => {
|
|
204
|
+
if (typeof value !== "object" || value === null || seen.has(value))
|
|
205
|
+
return null
|
|
206
|
+
seen.add(value)
|
|
207
|
+
if ("protocolCode" in value && typeof value.protocolCode === "string") {
|
|
208
|
+
return value
|
|
209
|
+
}
|
|
210
|
+
for (const nested of [
|
|
211
|
+
...Object.values(value),
|
|
212
|
+
...Object.getOwnPropertySymbols(value).map(
|
|
213
|
+
(symbol) => (value as Record<symbol, unknown>)[symbol],
|
|
214
|
+
),
|
|
215
|
+
]) {
|
|
216
|
+
const found = visit(nested)
|
|
217
|
+
if (found) return found
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
"_tag" in value &&
|
|
221
|
+
typeof value._tag === "string" &&
|
|
222
|
+
value._tag.endsWith("Error")
|
|
223
|
+
) {
|
|
224
|
+
return value
|
|
225
|
+
}
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
return visit(error) ?? error
|
|
229
|
+
}
|
|
230
|
+
|
|
201
231
|
const errorMessage = (error: unknown): string => {
|
|
202
232
|
if (
|
|
203
233
|
typeof error === "object" &&
|
|
@@ -219,6 +249,9 @@ const errorFields = (error: unknown): Record<string, unknown> => {
|
|
|
219
249
|
key !== "name" &&
|
|
220
250
|
key !== "message" &&
|
|
221
251
|
key !== "cause" &&
|
|
252
|
+
key !== "protocolCode" &&
|
|
253
|
+
key !== "retryable" &&
|
|
254
|
+
key !== "remediation" &&
|
|
222
255
|
value !== undefined,
|
|
223
256
|
),
|
|
224
257
|
)
|
|
@@ -231,17 +264,36 @@ export const successEnvelope = (result: unknown): SuccessEnvelope => ({
|
|
|
231
264
|
})
|
|
232
265
|
|
|
233
266
|
export const errorEnvelope = (error: unknown): ErrorEnvelope => {
|
|
234
|
-
const
|
|
267
|
+
const normalized = unwrapEffectError(error)
|
|
268
|
+
const defaults = errorMetadata[errorTag(normalized) ?? ""] ?? {
|
|
235
269
|
code: "COMMAND_FAILED",
|
|
236
270
|
retryable: false,
|
|
237
271
|
}
|
|
272
|
+
const dynamic =
|
|
273
|
+
typeof normalized === "object" && normalized !== null
|
|
274
|
+
? {
|
|
275
|
+
...("protocolCode" in normalized &&
|
|
276
|
+
typeof normalized.protocolCode === "string"
|
|
277
|
+
? { code: normalized.protocolCode }
|
|
278
|
+
: {}),
|
|
279
|
+
...("retryable" in normalized &&
|
|
280
|
+
typeof normalized.retryable === "boolean"
|
|
281
|
+
? { retryable: normalized.retryable }
|
|
282
|
+
: {}),
|
|
283
|
+
...("remediation" in normalized &&
|
|
284
|
+
typeof normalized.remediation === "string"
|
|
285
|
+
? { remediation: normalized.remediation }
|
|
286
|
+
: {}),
|
|
287
|
+
}
|
|
288
|
+
: {}
|
|
238
289
|
return {
|
|
239
290
|
version: PROTOCOL_VERSION,
|
|
240
291
|
ok: false,
|
|
241
292
|
error: {
|
|
242
|
-
...
|
|
243
|
-
|
|
244
|
-
|
|
293
|
+
...defaults,
|
|
294
|
+
...dynamic,
|
|
295
|
+
message: errorMessage(normalized),
|
|
296
|
+
fields: errorFields(normalized),
|
|
245
297
|
},
|
|
246
298
|
}
|
|
247
299
|
}
|
|
@@ -245,6 +245,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
245
245
|
readonly forwardOutput?: boolean
|
|
246
246
|
readonly passthrough?: boolean
|
|
247
247
|
readonly env?: Record<string, string>
|
|
248
|
+
readonly timeoutMs?: number
|
|
248
249
|
},
|
|
249
250
|
) =>
|
|
250
251
|
pipe(
|
|
@@ -264,6 +265,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
264
265
|
? "tee"
|
|
265
266
|
: "pipe",
|
|
266
267
|
env: options?.env,
|
|
268
|
+
timeoutMs: options?.timeoutMs,
|
|
267
269
|
}),
|
|
268
270
|
Effect.mapError(
|
|
269
271
|
(processError) =>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises"
|
|
4
|
+
import { join, resolve } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { errorEnvelope } from "../protocol"
|
|
6
7
|
import { PushService } from "./PushService"
|
|
7
8
|
import { TaskService } from "./TaskService"
|
|
8
9
|
import { WorktreeService } from "./WorktreeService"
|
|
@@ -130,9 +131,18 @@ describe("PushService", () => {
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
const publish = (
|
|
134
|
+
const publish = (
|
|
135
|
+
taskPath: string,
|
|
136
|
+
options: {
|
|
137
|
+
fetchTimeoutMs?: number
|
|
138
|
+
pushTimeoutMs?: number
|
|
139
|
+
retryDelayMs?: number
|
|
140
|
+
} = {},
|
|
141
|
+
) =>
|
|
134
142
|
runTestEffect(
|
|
135
|
-
PushService.pipe(
|
|
143
|
+
PushService.pipe(
|
|
144
|
+
Effect.flatMap((service) => service.publish(taskPath, options)),
|
|
145
|
+
),
|
|
136
146
|
)
|
|
137
147
|
|
|
138
148
|
const remoteBranch = async (remote: string) =>
|
|
@@ -157,6 +167,17 @@ describe("PushService", () => {
|
|
|
157
167
|
)
|
|
158
168
|
}
|
|
159
169
|
|
|
170
|
+
const prePushHook = async (checkout: string) =>
|
|
171
|
+
resolve(
|
|
172
|
+
checkout,
|
|
173
|
+
(
|
|
174
|
+
await requireCommand(
|
|
175
|
+
["git", "rev-parse", "--git-path", "hooks/pre-push"],
|
|
176
|
+
checkout,
|
|
177
|
+
)
|
|
178
|
+
).stdout,
|
|
179
|
+
)
|
|
180
|
+
|
|
160
181
|
test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
|
|
161
182
|
const fixture = await setup()
|
|
162
183
|
await configureAuthor(fixture.checkout)
|
|
@@ -299,4 +320,89 @@ describe("PushService", () => {
|
|
|
299
320
|
"has an invalid author",
|
|
300
321
|
)
|
|
301
322
|
})
|
|
323
|
+
|
|
324
|
+
test("bounds a stalled pre-push hook and confirms non-publication", async () => {
|
|
325
|
+
const fixture = await setup()
|
|
326
|
+
await configureAuthor(fixture.checkout)
|
|
327
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "timeout\n")
|
|
328
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
329
|
+
await requireCommand(
|
|
330
|
+
["git", "commit", "-m", "Add timed publication"],
|
|
331
|
+
fixture.checkout,
|
|
332
|
+
)
|
|
333
|
+
const hook = await prePushHook(fixture.checkout)
|
|
334
|
+
await Bun.write(hook, "#!/bin/sh\nsleep 30\n")
|
|
335
|
+
await chmod(hook, 0o755)
|
|
336
|
+
|
|
337
|
+
const startedAt = performance.now()
|
|
338
|
+
const failure = await publish(fixture.taskPath, {
|
|
339
|
+
pushTimeoutMs: 25,
|
|
340
|
+
}).catch((error) => error)
|
|
341
|
+
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
|
342
|
+
expect(errorEnvelope(failure).error).toMatchObject({
|
|
343
|
+
code: "PUSH_TIMEOUT",
|
|
344
|
+
fields: { category: "timeout", stage: "publish" },
|
|
345
|
+
retryable: true,
|
|
346
|
+
})
|
|
347
|
+
await expect(remoteBranch(fixture.remote)).rejects.toThrow()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test("classifies hook rejection separately from transport failure", async () => {
|
|
351
|
+
const fixture = await setup()
|
|
352
|
+
await configureAuthor(fixture.checkout)
|
|
353
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "rejected\n")
|
|
354
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
355
|
+
await requireCommand(
|
|
356
|
+
["git", "commit", "-m", "Add rejected publication"],
|
|
357
|
+
fixture.checkout,
|
|
358
|
+
)
|
|
359
|
+
const hook = await prePushHook(fixture.checkout)
|
|
360
|
+
await Bun.write(
|
|
361
|
+
hook,
|
|
362
|
+
"#!/bin/sh\necho 'pre-push hook declined' >&2\nexit 1\n",
|
|
363
|
+
)
|
|
364
|
+
await chmod(hook, 0o755)
|
|
365
|
+
|
|
366
|
+
const failure = await publish(fixture.taskPath).catch((error) => error)
|
|
367
|
+
expect(errorEnvelope(failure).error).toMatchObject({
|
|
368
|
+
code: "PUSH_HOOK_REJECTED",
|
|
369
|
+
fields: { category: "hook_rejection", stage: "publish" },
|
|
370
|
+
retryable: false,
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test("retries one transient fetch with non-interactive authentication", async () => {
|
|
375
|
+
const fixture = await setup()
|
|
376
|
+
await configureAuthor(fixture.checkout)
|
|
377
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "retried\n")
|
|
378
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
379
|
+
await requireCommand(
|
|
380
|
+
["git", "commit", "-m", "Add retried publication"],
|
|
381
|
+
fixture.checkout,
|
|
382
|
+
)
|
|
383
|
+
const attempts = join(fixture.root, "upload-pack-attempts")
|
|
384
|
+
const uploadPack = join(fixture.root, "upload-pack")
|
|
385
|
+
await Bun.write(
|
|
386
|
+
uploadPack,
|
|
387
|
+
`#!/bin/sh
|
|
388
|
+
echo x >> ${JSON.stringify(attempts)}
|
|
389
|
+
test "$GIT_TERMINAL_PROMPT" = 0 || exit 2
|
|
390
|
+
test "$GCM_INTERACTIVE" = Never || exit 2
|
|
391
|
+
if test "$(wc -l < ${JSON.stringify(attempts)})" -eq 1; then
|
|
392
|
+
echo 'Connection reset by peer' >&2
|
|
393
|
+
exit 1
|
|
394
|
+
fi
|
|
395
|
+
exec git-upload-pack "$@"
|
|
396
|
+
`,
|
|
397
|
+
)
|
|
398
|
+
await chmod(uploadPack, 0o755)
|
|
399
|
+
await requireCommand(
|
|
400
|
+
["git", "config", "remote.origin.uploadpack", uploadPack],
|
|
401
|
+
fixture.checkout,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
const result = await publish(fixture.taskPath, { retryDelayMs: 0 })
|
|
405
|
+
expect(result.tip).toBe(await remoteBranch(fixture.remote))
|
|
406
|
+
expect((await Bun.file(attempts).text()).trim().split("\n")).toHaveLength(2)
|
|
407
|
+
})
|
|
302
408
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Data, Effect } from "effect"
|
|
1
|
+
import { Data, Effect, Either } from "effect"
|
|
2
2
|
import { ContextService } from "./ContextService"
|
|
3
3
|
import { FileSystemService } from "./FileSystemService"
|
|
4
4
|
import { PhaseService } from "./PhaseService"
|
|
@@ -6,8 +6,77 @@ import { TaskService } from "./TaskService"
|
|
|
6
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
7
7
|
import { parseGitCommits, type PushCommitMetadata } from "./push-validation"
|
|
8
8
|
|
|
9
|
+
type PushCategory =
|
|
10
|
+
| "precondition"
|
|
11
|
+
| "commit_validation"
|
|
12
|
+
| "remote_divergence"
|
|
13
|
+
| "hook_rejection"
|
|
14
|
+
| "authentication"
|
|
15
|
+
| "transport"
|
|
16
|
+
| "timeout"
|
|
17
|
+
| "ambiguous_publication"
|
|
18
|
+
| "git"
|
|
19
|
+
|
|
20
|
+
const categoryMetadata: Record<
|
|
21
|
+
PushCategory,
|
|
22
|
+
{ code: string; retryable: boolean; remediation: string }
|
|
23
|
+
> = {
|
|
24
|
+
precondition: {
|
|
25
|
+
code: "PUSH_PRECONDITION",
|
|
26
|
+
retryable: false,
|
|
27
|
+
remediation: "Resolve the local publication precondition and retry.",
|
|
28
|
+
},
|
|
29
|
+
commit_validation: {
|
|
30
|
+
code: "PUSH_COMMIT_VALIDATION",
|
|
31
|
+
retryable: false,
|
|
32
|
+
remediation: "Rewrite the reported outgoing commits and retry.",
|
|
33
|
+
},
|
|
34
|
+
remote_divergence: {
|
|
35
|
+
code: "PUSH_REMOTE_DIVERGENCE",
|
|
36
|
+
retryable: false,
|
|
37
|
+
remediation: "Rebase onto the remote delivery branch before retrying.",
|
|
38
|
+
},
|
|
39
|
+
hook_rejection: {
|
|
40
|
+
code: "PUSH_HOOK_REJECTED",
|
|
41
|
+
retryable: false,
|
|
42
|
+
remediation: "Resolve the hook failure and retry; hooks are not bypassed.",
|
|
43
|
+
},
|
|
44
|
+
authentication: {
|
|
45
|
+
code: "PUSH_AUTHENTICATION",
|
|
46
|
+
retryable: false,
|
|
47
|
+
remediation: "Authenticate Git for the configured remote and retry.",
|
|
48
|
+
},
|
|
49
|
+
transport: {
|
|
50
|
+
code: "PUSH_TRANSPORT",
|
|
51
|
+
retryable: true,
|
|
52
|
+
remediation: "Check remote connectivity and retry.",
|
|
53
|
+
},
|
|
54
|
+
timeout: {
|
|
55
|
+
code: "PUSH_TIMEOUT",
|
|
56
|
+
retryable: true,
|
|
57
|
+
remediation: "Check remote connectivity and retry the bounded operation.",
|
|
58
|
+
},
|
|
59
|
+
ambiguous_publication: {
|
|
60
|
+
code: "PUSH_OUTCOME_UNKNOWN",
|
|
61
|
+
retryable: false,
|
|
62
|
+
remediation:
|
|
63
|
+
"Inspect the exact remote delivery ref before retrying publication.",
|
|
64
|
+
},
|
|
65
|
+
git: {
|
|
66
|
+
code: "PUSH_GIT_ERROR",
|
|
67
|
+
retryable: false,
|
|
68
|
+
remediation: "Resolve the reported Git failure and retry.",
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
9
72
|
class PushError extends Data.TaggedError("PushError")<{
|
|
10
73
|
readonly message: string
|
|
74
|
+
readonly category: PushCategory
|
|
75
|
+
readonly stage: PushStage
|
|
76
|
+
readonly elapsedMs?: number
|
|
77
|
+
readonly protocolCode: string
|
|
78
|
+
readonly retryable: boolean
|
|
79
|
+
readonly remediation: string
|
|
11
80
|
}> {}
|
|
12
81
|
|
|
13
82
|
interface CommandResult {
|
|
@@ -28,46 +97,162 @@ interface PushResult {
|
|
|
28
97
|
readonly tip: string
|
|
29
98
|
}
|
|
30
99
|
|
|
31
|
-
type PushStage =
|
|
100
|
+
type PushStage =
|
|
101
|
+
| "context"
|
|
102
|
+
| "inspect"
|
|
103
|
+
| "validate"
|
|
104
|
+
| "fetch"
|
|
105
|
+
| "publish"
|
|
106
|
+
| "reconcile"
|
|
32
107
|
|
|
33
108
|
interface PushOptions {
|
|
34
109
|
readonly onProgress?: (stage: PushStage) => void
|
|
110
|
+
readonly fetchTimeoutMs?: number
|
|
111
|
+
readonly pushTimeoutMs?: number
|
|
112
|
+
readonly retryDelayMs?: number
|
|
113
|
+
readonly forwardOutput?: boolean
|
|
35
114
|
}
|
|
36
115
|
|
|
37
116
|
const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
|
|
38
117
|
|
|
118
|
+
const pushError = (
|
|
119
|
+
message: string,
|
|
120
|
+
category: PushCategory,
|
|
121
|
+
stage: PushStage,
|
|
122
|
+
elapsedMs?: number,
|
|
123
|
+
) =>
|
|
124
|
+
new PushError({
|
|
125
|
+
message,
|
|
126
|
+
category,
|
|
127
|
+
stage,
|
|
128
|
+
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
|
129
|
+
protocolCode: categoryMetadata[category].code,
|
|
130
|
+
retryable: categoryMetadata[category].retryable,
|
|
131
|
+
remediation: categoryMetadata[category].remediation,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const processTimeout = (error: unknown): { elapsedMs?: number } | null => {
|
|
135
|
+
if (typeof error !== "object" || error === null) return null
|
|
136
|
+
if ("timedOut" in error && error.timedOut === true) {
|
|
137
|
+
return {
|
|
138
|
+
elapsedMs:
|
|
139
|
+
"elapsedMs" in error && typeof error.elapsedMs === "number"
|
|
140
|
+
? error.elapsedMs
|
|
141
|
+
: undefined,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return "cause" in error ? processTimeout(error.cause) : null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const classifyGitFailure = (
|
|
148
|
+
message: string,
|
|
149
|
+
stage: PushStage,
|
|
150
|
+
elapsedMs?: number,
|
|
151
|
+
) => {
|
|
152
|
+
const normalized = message.toLowerCase()
|
|
153
|
+
const category: PushCategory =
|
|
154
|
+
/authentication failed|permission denied|could not read username|terminal prompts disabled|credential/.test(
|
|
155
|
+
normalized,
|
|
156
|
+
)
|
|
157
|
+
? "authentication"
|
|
158
|
+
: /pre-push hook|hook declined|remote rejected/.test(normalized)
|
|
159
|
+
? "hook_rejection"
|
|
160
|
+
: /non-fast-forward|fetch first|stale info/.test(normalized)
|
|
161
|
+
? "remote_divergence"
|
|
162
|
+
: /could not resolve|connection|network|remote end hung up|unable to access|repository not found/.test(
|
|
163
|
+
normalized,
|
|
164
|
+
)
|
|
165
|
+
? "transport"
|
|
166
|
+
: "git"
|
|
167
|
+
return pushError(message, category, stage, elapsedMs)
|
|
168
|
+
}
|
|
169
|
+
|
|
39
170
|
const requireCommand = (
|
|
40
171
|
fs: FileSystemService,
|
|
41
172
|
args: readonly string[],
|
|
42
173
|
cwd: string,
|
|
43
174
|
label: string,
|
|
175
|
+
stage: PushStage,
|
|
176
|
+
options: {
|
|
177
|
+
readonly timeoutMs?: number
|
|
178
|
+
readonly env?: Record<string, string>
|
|
179
|
+
readonly forwardOutput?: boolean
|
|
180
|
+
} = {},
|
|
44
181
|
) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
182
|
+
Effect.suspend(() => {
|
|
183
|
+
const startedAt = performance.now()
|
|
184
|
+
return fs
|
|
185
|
+
.runCommand(args, {
|
|
186
|
+
cwd,
|
|
187
|
+
captureOutput: true,
|
|
188
|
+
forwardOutput: options.forwardOutput,
|
|
189
|
+
env: options.env,
|
|
190
|
+
timeoutMs: options.timeoutMs,
|
|
191
|
+
})
|
|
192
|
+
.pipe(
|
|
193
|
+
Effect.mapError((error) => {
|
|
194
|
+
const timeout = processTimeout(error)
|
|
195
|
+
const elapsedMs =
|
|
196
|
+
timeout?.elapsedMs ?? Math.round(performance.now() - startedAt)
|
|
197
|
+
return timeout
|
|
198
|
+
? pushError(
|
|
199
|
+
`${label} timed out after ${elapsedMs} ms`,
|
|
200
|
+
"timeout",
|
|
201
|
+
stage,
|
|
202
|
+
elapsedMs,
|
|
203
|
+
)
|
|
204
|
+
: classifyGitFailure(`${label}: ${error.message}`, stage, elapsedMs)
|
|
205
|
+
}),
|
|
206
|
+
Effect.flatMap((result) =>
|
|
207
|
+
result.exitCode === 0
|
|
208
|
+
? Effect.succeed(result)
|
|
209
|
+
: Effect.fail(
|
|
210
|
+
classifyGitFailure(
|
|
211
|
+
`${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
212
|
+
stage,
|
|
213
|
+
Math.round(performance.now() - startedAt),
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
})
|
|
56
219
|
|
|
57
220
|
const git = (
|
|
58
221
|
fs: FileSystemService,
|
|
59
222
|
cwd: string,
|
|
60
223
|
args: readonly string[],
|
|
61
224
|
label: string,
|
|
62
|
-
|
|
225
|
+
stage: PushStage,
|
|
226
|
+
options?: Parameters<typeof requireCommand>[5],
|
|
227
|
+
) => requireCommand(fs, ["git", ...args], cwd, label, stage, options)
|
|
63
228
|
|
|
64
|
-
const gitRevision = (
|
|
229
|
+
const gitRevision = (
|
|
230
|
+
fs: FileSystemService,
|
|
231
|
+
cwd: string,
|
|
232
|
+
revision: string,
|
|
233
|
+
stage: PushStage = "inspect",
|
|
234
|
+
) =>
|
|
65
235
|
fs
|
|
66
236
|
.runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
|
|
67
237
|
cwd,
|
|
68
238
|
captureOutput: true,
|
|
239
|
+
timeoutMs: 10_000,
|
|
69
240
|
})
|
|
70
241
|
.pipe(
|
|
242
|
+
Effect.mapError((error) => {
|
|
243
|
+
const timeout = processTimeout(error)
|
|
244
|
+
return timeout
|
|
245
|
+
? pushError(
|
|
246
|
+
`Git revision inspection timed out after ${timeout.elapsedMs ?? 10_000} ms`,
|
|
247
|
+
"timeout",
|
|
248
|
+
stage,
|
|
249
|
+
timeout.elapsedMs,
|
|
250
|
+
)
|
|
251
|
+
: classifyGitFailure(
|
|
252
|
+
`Failed to inspect Git revision '${revision}': ${error.message}`,
|
|
253
|
+
stage,
|
|
254
|
+
)
|
|
255
|
+
}),
|
|
71
256
|
Effect.map((result) =>
|
|
72
257
|
result.exitCode === 0 ? result.stdout.trim() || null : null,
|
|
73
258
|
),
|
|
@@ -78,20 +263,37 @@ const gitAncestor = (
|
|
|
78
263
|
cwd: string,
|
|
79
264
|
ancestor: string,
|
|
80
265
|
descendant: string,
|
|
266
|
+
stage: PushStage,
|
|
81
267
|
) =>
|
|
82
268
|
fs
|
|
83
269
|
.runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
|
|
84
270
|
cwd,
|
|
85
271
|
captureOutput: true,
|
|
272
|
+
timeoutMs: 10_000,
|
|
86
273
|
})
|
|
87
274
|
.pipe(
|
|
275
|
+
Effect.mapError((error) => {
|
|
276
|
+
const timeout = processTimeout(error)
|
|
277
|
+
return timeout
|
|
278
|
+
? pushError(
|
|
279
|
+
`Git ancestry inspection timed out after ${timeout.elapsedMs ?? 10_000} ms`,
|
|
280
|
+
"timeout",
|
|
281
|
+
stage,
|
|
282
|
+
timeout.elapsedMs,
|
|
283
|
+
)
|
|
284
|
+
: classifyGitFailure(
|
|
285
|
+
`Failed to inspect Git ancestry: ${error.message}`,
|
|
286
|
+
stage,
|
|
287
|
+
)
|
|
288
|
+
}),
|
|
88
289
|
Effect.flatMap((result) => {
|
|
89
290
|
if (result.exitCode === 0) return Effect.succeed(true)
|
|
90
291
|
if (result.exitCode === 1) return Effect.succeed(false)
|
|
91
292
|
return Effect.fail(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
293
|
+
classifyGitFailure(
|
|
294
|
+
`Failed to inspect Git ancestry: ${result.stderr.trim()}`,
|
|
295
|
+
stage,
|
|
296
|
+
),
|
|
95
297
|
)
|
|
96
298
|
}),
|
|
97
299
|
)
|
|
@@ -101,9 +303,11 @@ const validateGitCommits = (
|
|
|
101
303
|
base: string,
|
|
102
304
|
) => {
|
|
103
305
|
if (commits.length === 0) {
|
|
104
|
-
throw
|
|
105
|
-
|
|
106
|
-
|
|
306
|
+
throw pushError(
|
|
307
|
+
`No commits to publish after base '${base}'`,
|
|
308
|
+
"precondition",
|
|
309
|
+
"validate",
|
|
310
|
+
)
|
|
107
311
|
}
|
|
108
312
|
const issues: string[] = []
|
|
109
313
|
for (const commit of commits) {
|
|
@@ -118,66 +322,152 @@ const validateGitCommits = (
|
|
|
118
322
|
)
|
|
119
323
|
}
|
|
120
324
|
}
|
|
121
|
-
if (issues.length > 0)
|
|
325
|
+
if (issues.length > 0)
|
|
326
|
+
throw pushError(issues.join("\n"), "commit_validation", "validate")
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const positiveInteger = (value: string | undefined, fallback: number) => {
|
|
330
|
+
const parsed = Number.parseInt(value ?? "", 10)
|
|
331
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
|
|
122
332
|
}
|
|
123
333
|
|
|
334
|
+
const gitEnvironment = (): Record<string, string> => ({
|
|
335
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
336
|
+
GCM_INTERACTIVE: "Never",
|
|
337
|
+
GIT_SSH_COMMAND:
|
|
338
|
+
process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes -o ConnectTimeout=15",
|
|
339
|
+
})
|
|
340
|
+
|
|
124
341
|
const publishGit = (
|
|
125
342
|
fs: FileSystemService,
|
|
126
343
|
checkout: string,
|
|
127
344
|
remote: string,
|
|
128
345
|
branch: string,
|
|
129
346
|
base: string,
|
|
130
|
-
|
|
347
|
+
options: PushOptions,
|
|
131
348
|
) =>
|
|
132
349
|
Effect.gen(function* () {
|
|
350
|
+
const onProgress = options.onProgress
|
|
351
|
+
const fetchTimeoutMs =
|
|
352
|
+
options.fetchTimeoutMs ??
|
|
353
|
+
positiveInteger(process.env.AGENCY_PUSH_FETCH_TIMEOUT_MS, 30_000)
|
|
354
|
+
const pushTimeoutMs =
|
|
355
|
+
options.pushTimeoutMs ??
|
|
356
|
+
positiveInteger(process.env.AGENCY_PUSH_TIMEOUT_MS, 120_000)
|
|
357
|
+
const retryDelayMs = options.retryDelayMs ?? 250
|
|
358
|
+
const env = gitEnvironment()
|
|
133
359
|
onProgress?.("inspect")
|
|
134
360
|
const currentBranch = yield* git(
|
|
135
361
|
fs,
|
|
136
362
|
checkout,
|
|
137
363
|
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
138
364
|
"Git checkout must be attached to the declared branch",
|
|
365
|
+
"inspect",
|
|
139
366
|
)
|
|
140
367
|
if (currentBranch.stdout.trim() !== branch) {
|
|
141
|
-
return yield*
|
|
142
|
-
|
|
143
|
-
|
|
368
|
+
return yield* pushError(
|
|
369
|
+
`Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
|
|
370
|
+
"precondition",
|
|
371
|
+
"inspect",
|
|
372
|
+
)
|
|
144
373
|
}
|
|
145
374
|
const status = yield* git(
|
|
146
375
|
fs,
|
|
147
376
|
checkout,
|
|
148
377
|
["status", "--porcelain=v1"],
|
|
149
378
|
"Failed to inspect Git status",
|
|
379
|
+
"inspect",
|
|
150
380
|
)
|
|
151
381
|
if (status.stdout.length > 0) {
|
|
152
|
-
return yield*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
382
|
+
return yield* pushError(
|
|
383
|
+
"Cannot publish a dirty Git worktree; commit or discard changes first",
|
|
384
|
+
"precondition",
|
|
385
|
+
"inspect",
|
|
386
|
+
)
|
|
156
387
|
}
|
|
157
388
|
|
|
158
|
-
|
|
159
|
-
yield*
|
|
389
|
+
const initialTip = yield* gitRevision(fs, checkout, "HEAD")
|
|
390
|
+
const initialBase = yield* gitRevision(
|
|
160
391
|
fs,
|
|
161
392
|
checkout,
|
|
162
|
-
|
|
163
|
-
`Failed to fetch remote '${remote}'`,
|
|
393
|
+
`refs/remotes/${remote}/${base}`,
|
|
164
394
|
)
|
|
395
|
+
if (initialTip && initialBase) {
|
|
396
|
+
const cachedBaseIsAncestor = yield* gitAncestor(
|
|
397
|
+
fs,
|
|
398
|
+
checkout,
|
|
399
|
+
initialBase,
|
|
400
|
+
initialTip,
|
|
401
|
+
"validate",
|
|
402
|
+
)
|
|
403
|
+
if (cachedBaseIsAncestor) {
|
|
404
|
+
onProgress?.("validate")
|
|
405
|
+
const initialLog = yield* git(
|
|
406
|
+
fs,
|
|
407
|
+
checkout,
|
|
408
|
+
[
|
|
409
|
+
"log",
|
|
410
|
+
"--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
|
|
411
|
+
`${initialBase}..${initialTip}`,
|
|
412
|
+
],
|
|
413
|
+
"Failed to inspect outgoing Git commits",
|
|
414
|
+
"validate",
|
|
415
|
+
)
|
|
416
|
+
yield* Effect.try({
|
|
417
|
+
try: () =>
|
|
418
|
+
validateGitCommits(parseGitCommits(initialLog.stdout), base),
|
|
419
|
+
catch: (cause) => cause as PushError,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
onProgress?.("fetch")
|
|
425
|
+
const fetchRemote = (
|
|
426
|
+
attempt: number,
|
|
427
|
+
): Effect.Effect<CommandResult, PushError> =>
|
|
428
|
+
git(
|
|
429
|
+
fs,
|
|
430
|
+
checkout,
|
|
431
|
+
[
|
|
432
|
+
"fetch",
|
|
433
|
+
"--prune",
|
|
434
|
+
remote,
|
|
435
|
+
`+refs/heads/${base}:refs/remotes/${remote}/${base}`,
|
|
436
|
+
`+refs/heads/${branch}*:refs/remotes/${remote}/${branch}*`,
|
|
437
|
+
],
|
|
438
|
+
`Failed to fetch remote '${remote}'`,
|
|
439
|
+
"fetch",
|
|
440
|
+
{ timeoutMs: fetchTimeoutMs, env },
|
|
441
|
+
).pipe(
|
|
442
|
+
Effect.catchAll((error) =>
|
|
443
|
+
attempt < 2 && ["timeout", "transport"].includes(error.category)
|
|
444
|
+
? Effect.sleep(retryDelayMs + Math.floor(Math.random() * 100)).pipe(
|
|
445
|
+
Effect.flatMap(() => fetchRemote(attempt + 1)),
|
|
446
|
+
)
|
|
447
|
+
: Effect.fail(error),
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
yield* fetchRemote(1)
|
|
165
451
|
const [tip, baseRevision] = yield* Effect.all(
|
|
166
452
|
[
|
|
167
|
-
gitRevision(fs, checkout, "HEAD"),
|
|
168
|
-
gitRevision(fs, checkout, `refs/remotes/${remote}/${base}
|
|
453
|
+
gitRevision(fs, checkout, "HEAD", "validate"),
|
|
454
|
+
gitRevision(fs, checkout, `refs/remotes/${remote}/${base}`, "validate"),
|
|
169
455
|
],
|
|
170
456
|
{ concurrency: "unbounded" },
|
|
171
457
|
)
|
|
172
458
|
if (!tip || !baseRevision) {
|
|
173
|
-
return yield*
|
|
174
|
-
|
|
175
|
-
|
|
459
|
+
return yield* pushError(
|
|
460
|
+
`Declared base '${base}' was not found on remote '${remote}'`,
|
|
461
|
+
"precondition",
|
|
462
|
+
"validate",
|
|
463
|
+
)
|
|
176
464
|
}
|
|
177
|
-
if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
|
|
178
|
-
return yield*
|
|
179
|
-
|
|
180
|
-
|
|
465
|
+
if (!(yield* gitAncestor(fs, checkout, baseRevision, tip, "validate"))) {
|
|
466
|
+
return yield* pushError(
|
|
467
|
+
`Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
|
|
468
|
+
"precondition",
|
|
469
|
+
"validate",
|
|
470
|
+
)
|
|
181
471
|
}
|
|
182
472
|
|
|
183
473
|
onProgress?.("validate")
|
|
@@ -190,6 +480,7 @@ const publishGit = (
|
|
|
190
480
|
`${baseRevision}..${tip}`,
|
|
191
481
|
],
|
|
192
482
|
"Failed to inspect outgoing Git commits",
|
|
483
|
+
"validate",
|
|
193
484
|
)
|
|
194
485
|
yield* Effect.try({
|
|
195
486
|
try: () => validateGitCommits(parseGitCommits(log.stdout), base),
|
|
@@ -200,11 +491,17 @@ const publishGit = (
|
|
|
200
491
|
fs,
|
|
201
492
|
checkout,
|
|
202
493
|
`refs/remotes/${remote}/${branch}`,
|
|
494
|
+
"validate",
|
|
203
495
|
)
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
496
|
+
if (
|
|
497
|
+
remoteTip &&
|
|
498
|
+
!(yield* gitAncestor(fs, checkout, remoteTip, tip, "validate"))
|
|
499
|
+
) {
|
|
500
|
+
return yield* pushError(
|
|
501
|
+
`Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
|
|
502
|
+
"remote_divergence",
|
|
503
|
+
"validate",
|
|
504
|
+
)
|
|
208
505
|
}
|
|
209
506
|
|
|
210
507
|
onProgress?.("publish")
|
|
@@ -217,19 +514,58 @@ const publishGit = (
|
|
|
217
514
|
`+refs/heads/*:refs/remotes/${remote}/*`,
|
|
218
515
|
],
|
|
219
516
|
`Failed to configure remote '${remote}' tracking`,
|
|
517
|
+
"publish",
|
|
220
518
|
)
|
|
221
|
-
yield*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
519
|
+
const pushed = yield* Effect.either(
|
|
520
|
+
git(
|
|
521
|
+
fs,
|
|
522
|
+
checkout,
|
|
523
|
+
[
|
|
524
|
+
"push",
|
|
525
|
+
"-u",
|
|
526
|
+
remote,
|
|
527
|
+
`HEAD:refs/heads/${branch}`,
|
|
528
|
+
`--force-if-includes`,
|
|
529
|
+
],
|
|
530
|
+
`Failed to push declared branch '${branch}'`,
|
|
531
|
+
"publish",
|
|
532
|
+
{
|
|
533
|
+
timeoutMs: pushTimeoutMs,
|
|
534
|
+
env,
|
|
535
|
+
forwardOutput: options.forwardOutput,
|
|
536
|
+
},
|
|
537
|
+
),
|
|
232
538
|
)
|
|
539
|
+
if (Either.isLeft(pushed)) {
|
|
540
|
+
onProgress?.("reconcile")
|
|
541
|
+
const reconciled = yield* Effect.either(
|
|
542
|
+
git(
|
|
543
|
+
fs,
|
|
544
|
+
checkout,
|
|
545
|
+
["ls-remote", "--heads", remote, `refs/heads/${branch}`],
|
|
546
|
+
`Failed to reconcile remote branch '${branch}'`,
|
|
547
|
+
"reconcile",
|
|
548
|
+
{ timeoutMs: fetchTimeoutMs, env },
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
if (Either.isLeft(reconciled)) {
|
|
552
|
+
return yield* pushError(
|
|
553
|
+
`Publication outcome is unknown after '${pushed.left.message}' and remote reconciliation also failed: ${reconciled.left.message}`,
|
|
554
|
+
"ambiguous_publication",
|
|
555
|
+
"reconcile",
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
const reconciledTip = reconciled.right.stdout.split(/\s+/, 1)[0] || null
|
|
559
|
+
if (reconciledTip === tip) return { tip }
|
|
560
|
+
if (reconciledTip !== remoteTip) {
|
|
561
|
+
return yield* pushError(
|
|
562
|
+
`Publication outcome is unknown: remote branch '${branch}' changed to ${reconciledTip ?? "a missing ref"} while publishing ${tip}`,
|
|
563
|
+
"ambiguous_publication",
|
|
564
|
+
"reconcile",
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
return yield* pushed.left
|
|
568
|
+
}
|
|
233
569
|
return { tip }
|
|
234
570
|
})
|
|
235
571
|
|
|
@@ -249,48 +585,58 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
249
585
|
compact: true,
|
|
250
586
|
})
|
|
251
587
|
if (!context.validation.valid) {
|
|
252
|
-
return yield*
|
|
253
|
-
|
|
254
|
-
|
|
588
|
+
return yield* pushError(
|
|
589
|
+
"Cannot publish from an invalid Agency workbase",
|
|
590
|
+
"precondition",
|
|
591
|
+
"context",
|
|
592
|
+
)
|
|
255
593
|
}
|
|
256
594
|
if (context.target.kind !== "task" && context.target.kind !== "phase") {
|
|
257
|
-
return yield*
|
|
258
|
-
|
|
259
|
-
|
|
595
|
+
return yield* pushError(
|
|
596
|
+
"agency push must run from an execution task or phase",
|
|
597
|
+
"precondition",
|
|
598
|
+
"context",
|
|
599
|
+
)
|
|
260
600
|
}
|
|
261
601
|
if (
|
|
262
602
|
context.authority.mode !== "execution" ||
|
|
263
603
|
!context.authority.writable
|
|
264
604
|
) {
|
|
265
|
-
return yield*
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
605
|
+
return yield* pushError(
|
|
606
|
+
"Current Agency target has no writable execution authority",
|
|
607
|
+
"precondition",
|
|
608
|
+
"context",
|
|
609
|
+
)
|
|
269
610
|
}
|
|
270
611
|
if (
|
|
271
612
|
!context.workspace?.writable?.materialized ||
|
|
272
613
|
!context.workspace.writable.registered
|
|
273
614
|
) {
|
|
274
|
-
return yield*
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
615
|
+
return yield* pushError(
|
|
616
|
+
"Current Agency writable checkout is not materialized and registered",
|
|
617
|
+
"precondition",
|
|
618
|
+
"context",
|
|
619
|
+
)
|
|
278
620
|
}
|
|
279
621
|
const blockers = context.graph.readiness.blockers.filter(
|
|
280
622
|
(blocker) =>
|
|
281
623
|
blocker.kind === "dependency" || blocker.kind === "validation",
|
|
282
624
|
)
|
|
283
625
|
if (blockers.length > 0) {
|
|
284
|
-
return yield*
|
|
285
|
-
|
|
286
|
-
|
|
626
|
+
return yield* pushError(
|
|
627
|
+
`Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
|
|
628
|
+
"precondition",
|
|
629
|
+
"context",
|
|
630
|
+
)
|
|
287
631
|
}
|
|
288
632
|
|
|
289
633
|
const taskId = context.target.taskId
|
|
290
634
|
if (!taskId) {
|
|
291
|
-
return yield*
|
|
292
|
-
|
|
293
|
-
|
|
635
|
+
return yield* pushError(
|
|
636
|
+
"Current Agency execution target has no task ID",
|
|
637
|
+
"precondition",
|
|
638
|
+
"context",
|
|
639
|
+
)
|
|
294
640
|
}
|
|
295
641
|
const phaseId =
|
|
296
642
|
context.target.kind === "phase" ? context.target.phaseId : undefined
|
|
@@ -303,14 +649,18 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
303
649
|
: null
|
|
304
650
|
: task.data
|
|
305
651
|
if (!execution || "review" in execution) {
|
|
306
|
-
return yield*
|
|
307
|
-
|
|
308
|
-
|
|
652
|
+
return yield* pushError(
|
|
653
|
+
"Current Agency target is not a delivery execution unit",
|
|
654
|
+
"precondition",
|
|
655
|
+
"context",
|
|
656
|
+
)
|
|
309
657
|
}
|
|
310
658
|
if (execution.status !== "working") {
|
|
311
|
-
return yield*
|
|
312
|
-
|
|
313
|
-
|
|
659
|
+
return yield* pushError(
|
|
660
|
+
`Cannot publish Agency work with status '${execution.status}'; status must be working`,
|
|
661
|
+
"precondition",
|
|
662
|
+
"context",
|
|
663
|
+
)
|
|
314
664
|
}
|
|
315
665
|
const checkout = context.authority.writable.checkoutPath
|
|
316
666
|
const { config } = yield* workbase.loadConfig(context.workbase.root)
|
|
@@ -321,7 +671,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
321
671
|
remote,
|
|
322
672
|
execution.branch,
|
|
323
673
|
execution.base,
|
|
324
|
-
options
|
|
674
|
+
options,
|
|
325
675
|
)
|
|
326
676
|
return {
|
|
327
677
|
vcs: "git",
|
package/src/usage-log.test.ts
CHANGED
|
@@ -24,6 +24,13 @@ describe("usage logging", () => {
|
|
|
24
24
|
durationMs: 12.4,
|
|
25
25
|
outcome: "success",
|
|
26
26
|
exitStatus: 0,
|
|
27
|
+
...(commandPath === "context"
|
|
28
|
+
? {
|
|
29
|
+
vcs: "git" as const,
|
|
30
|
+
terminalStage: "publish",
|
|
31
|
+
category: "success",
|
|
32
|
+
}
|
|
33
|
+
: {}),
|
|
27
34
|
},
|
|
28
35
|
"1.2.3",
|
|
29
36
|
env,
|
|
@@ -35,7 +42,7 @@ describe("usage logging", () => {
|
|
|
35
42
|
)
|
|
36
43
|
expect(await exportUsageEvents(env)).toEqual([
|
|
37
44
|
expect.objectContaining({
|
|
38
|
-
version:
|
|
45
|
+
version: 2,
|
|
39
46
|
sessionId: "session-1",
|
|
40
47
|
sessionSequence: 1,
|
|
41
48
|
agencyVersion: "1.2.3",
|
|
@@ -48,6 +55,9 @@ describe("usage logging", () => {
|
|
|
48
55
|
expect.objectContaining({
|
|
49
56
|
sessionSequence: 2,
|
|
50
57
|
commandPath: "context",
|
|
58
|
+
vcs: "git",
|
|
59
|
+
terminalStage: "publish",
|
|
60
|
+
category: "success",
|
|
51
61
|
}),
|
|
52
62
|
])
|
|
53
63
|
})
|
package/src/usage-log.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"
|
|
|
2
2
|
import { mkdir } from "node:fs/promises"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
|
|
5
|
-
const USAGE_EVENT_VERSION =
|
|
5
|
+
const USAGE_EVENT_VERSION = 2 as const
|
|
6
6
|
const DEFAULT_RETENTION_DAYS = 90
|
|
7
7
|
|
|
8
8
|
export interface UsageEvent {
|
|
@@ -11,6 +11,9 @@ export interface UsageEvent {
|
|
|
11
11
|
readonly durationMs: number
|
|
12
12
|
readonly exitStatus: number
|
|
13
13
|
readonly outcome: "success" | "failure"
|
|
14
|
+
readonly vcs?: "git"
|
|
15
|
+
readonly terminalStage?: string
|
|
16
|
+
readonly category?: string
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
const stateDirectory = (env: NodeJS.ProcessEnv) =>
|
|
@@ -50,6 +53,13 @@ const openDatabase = async (env: NodeJS.ProcessEnv) => {
|
|
|
50
53
|
exit_status INTEGER NOT NULL
|
|
51
54
|
)
|
|
52
55
|
`)
|
|
56
|
+
for (const column of ["vcs", "terminal_stage", "category"]) {
|
|
57
|
+
try {
|
|
58
|
+
database.run(`ALTER TABLE usage_events ADD COLUMN ${column} TEXT`)
|
|
59
|
+
} catch {
|
|
60
|
+
// Existing databases already have migrated columns.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
53
63
|
database.run(
|
|
54
64
|
"CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
|
|
55
65
|
)
|
|
@@ -74,11 +84,11 @@ export async function recordUsageEvent(
|
|
|
74
84
|
INSERT INTO usage_events (
|
|
75
85
|
event_version, session_id, session_sequence, occurred_at,
|
|
76
86
|
agency_version, command_path, flag_names, duration_ms,
|
|
77
|
-
outcome, exit_status
|
|
87
|
+
outcome, exit_status, vcs, terminal_stage, category
|
|
78
88
|
) VALUES (
|
|
79
89
|
?, ?,
|
|
80
90
|
(SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
|
|
81
|
-
?, ?, ?, ?, ?, ?, ?
|
|
91
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
82
92
|
)
|
|
83
93
|
`)
|
|
84
94
|
.run(
|
|
@@ -92,6 +102,9 @@ export async function recordUsageEvent(
|
|
|
92
102
|
Math.max(0, Math.round(event.durationMs)),
|
|
93
103
|
event.outcome,
|
|
94
104
|
event.exitStatus,
|
|
105
|
+
event.vcs ?? null,
|
|
106
|
+
event.terminalStage ?? null,
|
|
107
|
+
event.category ?? null,
|
|
95
108
|
)
|
|
96
109
|
if (Math.random() < 0.01) {
|
|
97
110
|
const days = retentionDays(env)
|
|
@@ -119,7 +132,7 @@ export async function exportUsageEvents(
|
|
|
119
132
|
.query(`
|
|
120
133
|
SELECT event_version, session_id, session_sequence, occurred_at,
|
|
121
134
|
agency_version, command_path, flag_names, duration_ms,
|
|
122
|
-
outcome, exit_status
|
|
135
|
+
outcome, exit_status, vcs, terminal_stage, category
|
|
123
136
|
FROM usage_events ORDER BY occurred_at, id
|
|
124
137
|
`)
|
|
125
138
|
.all() as Record<string, string | number>[]
|
|
@@ -134,6 +147,11 @@ export async function exportUsageEvents(
|
|
|
134
147
|
durationMs: row.duration_ms,
|
|
135
148
|
outcome: row.outcome,
|
|
136
149
|
exitStatus: row.exit_status,
|
|
150
|
+
...(row.vcs == null ? {} : { vcs: row.vcs }),
|
|
151
|
+
...(row.terminal_stage == null
|
|
152
|
+
? {}
|
|
153
|
+
: { terminalStage: row.terminal_stage }),
|
|
154
|
+
...(row.category == null ? {} : { category: row.category }),
|
|
137
155
|
}))
|
|
138
156
|
} catch {
|
|
139
157
|
return []
|
|
@@ -64,4 +64,14 @@ describe("spawnProcess", () => {
|
|
|
64
64
|
expect(result.stderr).toContain("err:0:")
|
|
65
65
|
expect(result.stderr).toContain(`err:${lineCount - 1}:`)
|
|
66
66
|
})
|
|
67
|
+
|
|
68
|
+
test("terminates timed-out process groups", async () => {
|
|
69
|
+
const startedAt = performance.now()
|
|
70
|
+
await expect(
|
|
71
|
+
Effect.runPromise(
|
|
72
|
+
spawnProcess(["sh", "-c", "sleep 30 & wait"], { timeoutMs: 25 }),
|
|
73
|
+
),
|
|
74
|
+
).rejects.toThrow("Process timed out")
|
|
75
|
+
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
|
76
|
+
})
|
|
67
77
|
})
|
package/src/utils/process.ts
CHANGED
|
@@ -18,6 +18,7 @@ interface SpawnOptions {
|
|
|
18
18
|
readonly stdout?: "pipe" | "inherit" | "tee"
|
|
19
19
|
readonly stderr?: "pipe" | "inherit" | "tee"
|
|
20
20
|
readonly env?: Record<string, string>
|
|
21
|
+
readonly timeoutMs?: number
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const readOutput = async (
|
|
@@ -45,8 +46,14 @@ class ProcessError extends Data.TaggedError("ProcessError")<{
|
|
|
45
46
|
command: string
|
|
46
47
|
exitCode: number
|
|
47
48
|
stderr: string
|
|
49
|
+
timedOut?: boolean
|
|
50
|
+
timeoutMs?: number
|
|
51
|
+
elapsedMs?: number
|
|
48
52
|
}> {
|
|
49
53
|
override get message(): string {
|
|
54
|
+
if (this.timedOut) {
|
|
55
|
+
return `Process timed out after ${this.elapsedMs ?? this.timeoutMs} ms: ${this.command}${this.stderr ? `\n${this.stderr}` : ""}`
|
|
56
|
+
}
|
|
50
57
|
return (
|
|
51
58
|
this.stderr ||
|
|
52
59
|
`Process failed with exit code ${this.exitCode}: ${this.command}`
|
|
@@ -65,12 +72,14 @@ export const spawnProcess = (
|
|
|
65
72
|
): Effect.Effect<ProcessResult, ProcessError> =>
|
|
66
73
|
Effect.tryPromise({
|
|
67
74
|
try: async () => {
|
|
75
|
+
const startedAt = performance.now()
|
|
68
76
|
const proc = Bun.spawn([...args], {
|
|
69
77
|
cwd: options?.cwd ?? process.cwd(),
|
|
70
78
|
stdin: options?.stdin ?? "pipe",
|
|
71
79
|
stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
|
|
72
80
|
stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
|
|
73
81
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
82
|
+
detached: options?.timeoutMs !== undefined,
|
|
74
83
|
})
|
|
75
84
|
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
76
85
|
// cannot block on filled pipe buffers before they exit.
|
|
@@ -89,11 +98,53 @@ export const spawnProcess = (
|
|
|
89
98
|
options?.stderr === "tee" ? process.stderr : undefined,
|
|
90
99
|
)
|
|
91
100
|
|
|
101
|
+
let timedOut = false
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
103
|
+
const exited =
|
|
104
|
+
options?.timeoutMs === undefined
|
|
105
|
+
? proc.exited
|
|
106
|
+
: Promise.race([
|
|
107
|
+
proc.exited,
|
|
108
|
+
new Promise<number>((resolve) => {
|
|
109
|
+
timer = setTimeout(async () => {
|
|
110
|
+
timedOut = true
|
|
111
|
+
try {
|
|
112
|
+
process.kill(-proc.pid, "SIGTERM")
|
|
113
|
+
} catch {
|
|
114
|
+
proc.kill("SIGTERM")
|
|
115
|
+
}
|
|
116
|
+
const stopped = await Promise.race([
|
|
117
|
+
proc.exited.then(() => true),
|
|
118
|
+
Bun.sleep(250).then(() => false),
|
|
119
|
+
])
|
|
120
|
+
if (!stopped) {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(-proc.pid, "SIGKILL")
|
|
123
|
+
} catch {
|
|
124
|
+
proc.kill("SIGKILL")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
resolve(await proc.exited)
|
|
128
|
+
}, options.timeoutMs)
|
|
129
|
+
}),
|
|
130
|
+
])
|
|
92
131
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
93
|
-
|
|
132
|
+
exited,
|
|
94
133
|
stdoutPromise,
|
|
95
134
|
stderrPromise,
|
|
96
135
|
])
|
|
136
|
+
if (timer) clearTimeout(timer)
|
|
137
|
+
if (timedOut) {
|
|
138
|
+
throw new ProcessError({
|
|
139
|
+
command: args.join(" "),
|
|
140
|
+
exitCode:
|
|
141
|
+
typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
|
|
142
|
+
stderr: stderr.trim(),
|
|
143
|
+
timedOut: true,
|
|
144
|
+
timeoutMs: options?.timeoutMs,
|
|
145
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
97
148
|
|
|
98
149
|
return {
|
|
99
150
|
stdout: stdout.trim(),
|
|
@@ -103,9 +154,11 @@ export const spawnProcess = (
|
|
|
103
154
|
}
|
|
104
155
|
},
|
|
105
156
|
catch: (error) =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
157
|
+
error instanceof ProcessError
|
|
158
|
+
? error
|
|
159
|
+
: new ProcessError({
|
|
160
|
+
command: args.join(" "),
|
|
161
|
+
exitCode: -1,
|
|
162
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
163
|
+
}),
|
|
111
164
|
})
|