@markjaquith/agency 2.56.1 → 2.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +35 -9
  2. package/package.json +1 -1
  3. package/src/cli-parser.test.ts +8 -0
  4. package/src/cli-parser.ts +7 -1
  5. package/src/commands/archive.test.ts +39 -1
  6. package/src/commands/archive.ts +25 -2
  7. package/src/commands/pr.test.ts +40 -1
  8. package/src/commands/pr.ts +6 -0
  9. package/src/commands/restore.test.ts +8 -0
  10. package/src/services/ArchiveBulkService.test.ts +485 -0
  11. package/src/services/ArchiveService.test.ts +164 -1
  12. package/src/services/ArchiveService.ts +447 -29
  13. package/src/services/ContextService.ts +27 -10
  14. package/src/services/DoctorService.ts +6 -11
  15. package/src/services/GraphService.ts +60 -102
  16. package/src/services/PullRequestService.test.ts +45 -1
  17. package/src/services/PullRequestService.ts +18 -1
  18. package/src/services/PushService.test.ts +10 -13
  19. package/src/services/RepositoryService.test.ts +6 -1
  20. package/src/services/RepositoryService.ts +57 -114
  21. package/src/services/ReviewService.test.ts +2 -2
  22. package/src/services/ReviewService.ts +47 -28
  23. package/src/services/SyncService.test.ts +17 -2
  24. package/src/services/SyncService.ts +41 -18
  25. package/src/services/TaskService.ts +18 -2
  26. package/src/services/VcsMigrationService.test.ts +58 -1
  27. package/src/services/VcsMigrationService.ts +9 -0
  28. package/src/services/VersionControlService.test.ts +38 -0
  29. package/src/services/VersionControlService.ts +211 -2
  30. package/src/services/WorktreeService.test.ts +4 -0
  31. package/src/services/WorktreeService.ts +1 -5
  32. package/src/vcs-status-fast.ts +3 -8
  33. package/src/workbase/delivery-command.test.ts +11 -2
  34. package/src/workbase/delivery-command.ts +5 -1
@@ -44,6 +44,7 @@ import {
44
44
  type TransactionStep,
45
45
  } from "./LifecycleTransaction"
46
46
  import { withWorktreeLocks } from "./WorktreeLock"
47
+ import { aggregateProgress, isTerminalStatus } from "../readiness"
47
48
 
48
49
  class ArchiveError extends Data.TaggedError("ArchiveError")<{
49
50
  readonly message: string
@@ -104,6 +105,36 @@ interface LifecycleOptions {
104
105
  readonly dryRun?: boolean
105
106
  }
106
107
 
108
+ type TaskArchiveSkipCode =
109
+ | "non-terminal"
110
+ | "active-claim"
111
+ | "dirty-worktree"
112
+ | "checkout-preflight-failed"
113
+ | "retained-dependent"
114
+ | "destination-exists"
115
+
116
+ interface TaskArchiveDisposition {
117
+ readonly id: string
118
+ readonly disposition: "archived" | "planned" | "skipped"
119
+ readonly reason?: {
120
+ readonly code: TaskArchiveSkipCode
121
+ readonly details: readonly string[]
122
+ }
123
+ readonly path?: string
124
+ readonly affectedPaths: readonly string[]
125
+ readonly removedWorktrees: readonly string[]
126
+ }
127
+
128
+ interface BulkTaskArchiveResult {
129
+ readonly operation: "archive"
130
+ readonly kind: "tasks"
131
+ readonly tasks: readonly TaskArchiveDisposition[]
132
+ readonly affectedPaths: readonly string[]
133
+ readonly removedWorktrees: readonly string[]
134
+ readonly dryRun: boolean
135
+ readonly at: string
136
+ }
137
+
107
138
  export interface ArchiveFilters {
108
139
  readonly kinds?: readonly string[]
109
140
  readonly statuses?: readonly string[]
@@ -128,6 +159,69 @@ interface Write {
128
159
  readonly content: string
129
160
  }
130
161
 
162
+ interface TaskArchiveContext {
163
+ readonly task: TaskRecord
164
+ readonly phases: readonly PhaseRecord[]
165
+ readonly executionUnits: readonly { taskId: string; phaseId?: string }[]
166
+ readonly terminal: boolean
167
+ readonly terminalDetails: readonly string[]
168
+ readonly activeClaims: readonly string[]
169
+ }
170
+
171
+ const loadTaskArchiveContext = (task: TaskRecord, root: string) =>
172
+ Effect.gen(function* () {
173
+ const phases = yield* PhaseService
174
+ if (!("phases" in task.data)) {
175
+ return {
176
+ task,
177
+ phases: [],
178
+ executionUnits: [{ taskId: task.id }],
179
+ terminal: isTerminalStatus(task.data.status),
180
+ terminalDetails: [`status=${task.data.status}`],
181
+ activeClaims:
182
+ task.data.claim?.state === "active" ? [`task:${task.id}`] : [],
183
+ } satisfies TaskArchiveContext
184
+ }
185
+
186
+ const phaseRecords: PhaseRecord[] = []
187
+ for (const declaration of task.data.phases) {
188
+ phaseRecords.push(yield* phases.show(task.id, declaration.id, root))
189
+ }
190
+ const statuses = phaseRecords.map((phase) => phase.data.status)
191
+ const effectiveStatus = aggregateProgress(statuses).status
192
+ return {
193
+ task,
194
+ phases: phaseRecords,
195
+ executionUnits: phaseRecords.map((phase) => ({
196
+ taskId: task.id,
197
+ phaseId: phase.id,
198
+ })),
199
+ terminal: phaseRecords.length > 0 && isTerminalStatus(effectiveStatus),
200
+ terminalDetails:
201
+ phaseRecords.length === 0
202
+ ? ["multi-phase task has no phases"]
203
+ : phaseRecords.map(
204
+ (phase) => `phase:${phase.id}:status=${phase.data.status}`,
205
+ ),
206
+ activeClaims: phaseRecords
207
+ .filter((phase) => phase.data.claim?.state === "active")
208
+ .map((phase) => `phase:${task.id}/${phase.id}`),
209
+ } satisfies TaskArchiveContext
210
+ })
211
+
212
+ const archiveEligibilityError = (context: TaskArchiveContext) => {
213
+ if (!context.terminal) {
214
+ return `Task '${context.task.id}' is not terminal (${context.terminalDetails.join(", ")}); only done or dropped tasks can be archived`
215
+ }
216
+ if (context.activeClaims.length > 0) {
217
+ return `Task '${context.task.id}' has active claims (${context.activeClaims.join(", ")}); release or finish them before archiving`
218
+ }
219
+ return undefined
220
+ }
221
+
222
+ const executionPhaseId = (unit: { taskId: string; phaseId?: string }) =>
223
+ unit.phaseId
224
+
131
225
  const decode = <S extends Schema.Schema.AnyNoContext>(
132
226
  schema: S,
133
227
  input: unknown,
@@ -735,6 +829,336 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
735
829
  } satisfies LifecycleResult
736
830
  }),
737
831
 
832
+ archiveTasks: (
833
+ startPath: string = process.cwd(),
834
+ options: LifecycleOptions = {},
835
+ ) =>
836
+ Effect.gen(function* () {
837
+ const fs = yield* FileSystemService
838
+ const workbase = yield* WorkbaseService
839
+ const epics = yield* EpicService
840
+ const tasks = yield* TaskService
841
+ const worktrees = yield* WorktreeService
842
+ const root = yield* workbase.discover(startPath)
843
+ const validation = yield* workbase.validate(root)
844
+ if (!validation.valid) {
845
+ return yield* new ArchiveError({
846
+ message: `Cannot plan bulk task archive because the workbase is invalid:\n${validation.issues
847
+ .map((issue) => `- ${issue.path}: ${issue.message}`)
848
+ .join("\n")}`,
849
+ })
850
+ }
851
+
852
+ const taskRecords = yield* tasks.list(root)
853
+ const epicRecords = yield* epics.list(root)
854
+ const epicById = new Map(epicRecords.map((epic) => [epic.id, epic]))
855
+ const contexts = new Map<string, TaskArchiveContext>()
856
+ const skipped = new Map<
857
+ string,
858
+ NonNullable<TaskArchiveDisposition["reason"]>
859
+ >()
860
+ const removedByTask = new Map<string, string[]>()
861
+
862
+ for (const task of taskRecords) {
863
+ const context = yield* loadTaskArchiveContext(task, root)
864
+ contexts.set(task.id, context)
865
+ if (!context.terminal) {
866
+ skipped.set(task.id, {
867
+ code: "non-terminal",
868
+ details: context.terminalDetails,
869
+ })
870
+ continue
871
+ }
872
+ if (context.activeClaims.length > 0) {
873
+ skipped.set(task.id, {
874
+ code: "active-claim",
875
+ details: context.activeClaims,
876
+ })
877
+ continue
878
+ }
879
+ const destination = archivedTaskDirectory(root, task.id)
880
+ if (yield* fs.exists(destination)) {
881
+ skipped.set(task.id, {
882
+ code: "destination-exists",
883
+ details: [destination],
884
+ })
885
+ continue
886
+ }
887
+
888
+ const removed: string[] = []
889
+ let preflightFailure: unknown
890
+ for (const unit of context.executionUnits) {
891
+ const result = yield* worktrees
892
+ .remove(unit.taskId, executionPhaseId(unit), root, {
893
+ dryRun: true,
894
+ })
895
+ .pipe(Effect.either)
896
+ if (Either.isLeft(result)) {
897
+ preflightFailure = result.left
898
+ break
899
+ }
900
+ removed.push(...result.right)
901
+ }
902
+ if (preflightFailure) {
903
+ if (
904
+ typeof preflightFailure !== "object" ||
905
+ preflightFailure === null ||
906
+ !("_tag" in preflightFailure) ||
907
+ preflightFailure._tag !== "WorktreeError"
908
+ ) {
909
+ return yield* Effect.fail(preflightFailure)
910
+ }
911
+ const message =
912
+ "message" in preflightFailure &&
913
+ typeof preflightFailure.message === "string"
914
+ ? preflightFailure.message
915
+ : "Managed checkout removal preflight failed"
916
+ const conflicts =
917
+ "conflicts" in preflightFailure &&
918
+ Array.isArray(preflightFailure.conflicts)
919
+ ? preflightFailure.conflicts
920
+ : []
921
+ if (
922
+ /^Failed to inspect worktrees/.test(message) ||
923
+ conflicts.some(
924
+ (conflict) =>
925
+ typeof conflict === "object" &&
926
+ conflict !== null &&
927
+ "kind" in conflict &&
928
+ conflict.kind === "inspection-failed",
929
+ )
930
+ ) {
931
+ return yield* Effect.fail(preflightFailure)
932
+ }
933
+ const dirty =
934
+ /uncommitted changes|dirty/i.test(message) ||
935
+ conflicts.some(
936
+ (conflict) =>
937
+ typeof conflict === "object" &&
938
+ conflict !== null &&
939
+ "dirty" in conflict &&
940
+ conflict.dirty === true,
941
+ )
942
+ skipped.set(task.id, {
943
+ code: dirty ? "dirty-worktree" : "checkout-preflight-failed",
944
+ details: [message],
945
+ })
946
+ continue
947
+ }
948
+ removedByTask.set(task.id, [...new Set(removed)].sort())
949
+ }
950
+
951
+ const cohort = new Set(
952
+ taskRecords
953
+ .filter((task) => !skipped.has(task.id))
954
+ .map((task) => task.id),
955
+ )
956
+ let changed = true
957
+ while (changed) {
958
+ changed = false
959
+ for (const taskId of [...cohort].sort()) {
960
+ const task = contexts.get(taskId)!.task
961
+ if (!task.data.epic) continue
962
+ const parent = epicById.get(task.data.epic)!
963
+ const dependents = parent.data.tasks
964
+ .filter((candidate) => candidate.dependsOn?.includes(taskId))
965
+ .map((candidate) => candidate.id)
966
+ .filter((dependent) => !cohort.has(dependent))
967
+ .sort()
968
+ if (dependents.length === 0) continue
969
+ cohort.delete(taskId)
970
+ skipped.set(taskId, {
971
+ code: "retained-dependent",
972
+ details: dependents,
973
+ })
974
+ changed = true
975
+ }
976
+ }
977
+
978
+ const selected = [...cohort].sort().map((id) => contexts.get(id)!)
979
+ const at = new Date().toISOString()
980
+ const writes: (Write & { create?: boolean })[] = []
981
+ const selectedByEpic = new Map<string, Set<string>>()
982
+ for (const context of selected) {
983
+ const epicId = context.task.data.epic
984
+ if (!epicId) continue
985
+ const ids = selectedByEpic.get(epicId) ?? new Set<string>()
986
+ ids.add(context.task.id)
987
+ selectedByEpic.set(epicId, ids)
988
+ }
989
+ for (const [epicId, ids] of [...selectedByEpic].sort(([a], [b]) =>
990
+ a.localeCompare(b),
991
+ )) {
992
+ const parent = epicById.get(epicId)!
993
+ const remaining = parent.data.tasks.filter(
994
+ (task) => !ids.has(task.id),
995
+ )
996
+ const dependencyIssue = validateDependencies(remaining, "Tasks")
997
+ if (dependencyIssue) {
998
+ return yield* new ArchiveError({
999
+ message: `Cannot archive task cohort from epic '${epicId}': ${dependencyIssue}`,
1000
+ })
1001
+ }
1002
+ writes.push({
1003
+ path: parent.path,
1004
+ content: yield* declarationContent(parent, {
1005
+ ...parent.data,
1006
+ tasks: remaining,
1007
+ }),
1008
+ })
1009
+ }
1010
+
1011
+ for (const context of selected) {
1012
+ const task = context.task
1013
+ const destination = archivedTaskDirectory(root, task.id)
1014
+ const parent = task.data.epic
1015
+ ? epicById.get(task.data.epic)
1016
+ : undefined
1017
+ const declaration = parent?.data.tasks.find(
1018
+ (candidate) => candidate.id === task.id,
1019
+ )
1020
+ const manifestPath = lifecycleManifestPath(dirname(task.path))
1021
+ writes.push({
1022
+ path: manifestPath,
1023
+ create: !(yield* fs.exists(manifestPath)),
1024
+ content: json(
1025
+ manifestFor(
1026
+ yield* readManifest(dirname(task.path)),
1027
+ {
1028
+ kind: "task",
1029
+ id: task.id,
1030
+ ...(parent && declaration
1031
+ ? {
1032
+ parent: {
1033
+ kind: "epic" as const,
1034
+ id: parent.id,
1035
+ declaration,
1036
+ },
1037
+ }
1038
+ : {}),
1039
+ },
1040
+ event(root, "archive", at, dirname(task.path), destination),
1041
+ ),
1042
+ ),
1043
+ })
1044
+ }
1045
+ writes.sort((a, b) => a.path.localeCompare(b.path))
1046
+
1047
+ const executionUnits = selected
1048
+ .flatMap((context) => context.executionUnits)
1049
+ .sort((a, b) =>
1050
+ `${a.taskId}/${a.phaseId ?? ""}`.localeCompare(
1051
+ `${b.taskId}/${b.phaseId ?? ""}`,
1052
+ ),
1053
+ )
1054
+ if (!options.dryRun && selected.length > 0) {
1055
+ const snapshots: WorktreeRemovalSnapshot[] = []
1056
+ const steps: TransactionStep[] = [
1057
+ documentWriteStep(root, writes),
1058
+ {
1059
+ label: "remove worktrees for task archive cohort",
1060
+ apply: async () => {
1061
+ try {
1062
+ for (const unit of executionUnits) {
1063
+ await runWorktreeEffect(
1064
+ worktrees.remove(unit.taskId, unit.phaseId, root, {
1065
+ snapshots,
1066
+ lockHeld: true,
1067
+ }),
1068
+ )
1069
+ }
1070
+ } catch (cause) {
1071
+ await restoreWorktreeSnapshots(snapshots)
1072
+ throw cause
1073
+ }
1074
+ },
1075
+ rollback: () => restoreWorktreeSnapshots(snapshots),
1076
+ manualRecovery:
1077
+ "Run agency work prepare for each archived execution unit",
1078
+ },
1079
+ ]
1080
+ for (const context of selected) {
1081
+ steps.push(
1082
+ directoryMoveStep(
1083
+ root,
1084
+ dirname(context.task.path),
1085
+ archivedTaskDirectory(root, context.task.id),
1086
+ ),
1087
+ )
1088
+ }
1089
+ const parentPreconditions = [...selectedByEpic.keys()]
1090
+ .sort()
1091
+ .map((id) => epicById.get(id)!)
1092
+ .map((epic) => ({ path: epic.path, revision: epic.revision }))
1093
+ yield* withLifecycleLock(
1094
+ root,
1095
+ withWorktreeLocks(
1096
+ root,
1097
+ executionUnits,
1098
+ runLifecycleTransaction({
1099
+ root,
1100
+ preconditions: [
1101
+ ...parentPreconditions,
1102
+ ...selected.flatMap((context) => [
1103
+ {
1104
+ path: context.task.path,
1105
+ revision: context.task.revision,
1106
+ },
1107
+ ...context.phases.map((phase) => ({
1108
+ path: phase.path,
1109
+ revision: phase.revision,
1110
+ })),
1111
+ ]),
1112
+ ],
1113
+ steps,
1114
+ }),
1115
+ ),
1116
+ )
1117
+ }
1118
+
1119
+ const dispositions = taskRecords
1120
+ .map((task): TaskArchiveDisposition => {
1121
+ const reason = skipped.get(task.id)
1122
+ if (reason) {
1123
+ return {
1124
+ id: task.id,
1125
+ disposition: "skipped",
1126
+ reason,
1127
+ affectedPaths: [],
1128
+ removedWorktrees: [],
1129
+ }
1130
+ }
1131
+ const path = archivedTaskDirectory(root, task.id)
1132
+ const parentPath = task.data.epic
1133
+ ? epicById.get(task.data.epic)?.path
1134
+ : undefined
1135
+ return {
1136
+ id: task.id,
1137
+ disposition: options.dryRun ? "planned" : "archived",
1138
+ path,
1139
+ affectedPaths: [
1140
+ path,
1141
+ ...(parentPath ? [parentPath] : []),
1142
+ ].sort(),
1143
+ removedWorktrees: removedByTask.get(task.id) ?? [],
1144
+ }
1145
+ })
1146
+ .sort((a, b) => a.id.localeCompare(b.id))
1147
+ return {
1148
+ operation: "archive",
1149
+ kind: "tasks",
1150
+ tasks: dispositions,
1151
+ affectedPaths: [
1152
+ ...new Set(dispositions.flatMap((item) => item.affectedPaths)),
1153
+ ].sort(),
1154
+ removedWorktrees: [
1155
+ ...new Set(dispositions.flatMap((item) => item.removedWorktrees)),
1156
+ ].sort(),
1157
+ dryRun: options.dryRun === true,
1158
+ at,
1159
+ } satisfies BulkTaskArchiveResult
1160
+ }),
1161
+
738
1162
  archiveTask: (
739
1163
  id: string,
740
1164
  startPath: string = process.cwd(),
@@ -748,9 +1172,11 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
748
1172
  const worktrees = yield* WorktreeService
749
1173
  const root = yield* workbase.discover(startPath)
750
1174
  const task = yield* tasks.show(id, root)
751
- if ("claim" in task.data && task.data.claim?.state === "active") {
1175
+ const archiveContext = yield* loadTaskArchiveContext(task, root)
1176
+ const eligibilityError = archiveEligibilityError(archiveContext)
1177
+ if (eligibilityError) {
752
1178
  return yield* new ArchiveError({
753
- message: `Task '${id}' has an active claim; release or finish it before archiving`,
1179
+ message: eligibilityError,
754
1180
  })
755
1181
  }
756
1182
  const destination = archivedTaskDirectory(root, id)
@@ -775,32 +1201,19 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
775
1201
  }
776
1202
  }
777
1203
 
778
- const executionUnits: { taskId: string; phaseId?: string }[] = []
779
- const phaseRecords: PhaseRecord[] = []
780
- if ("phases" in task.data) {
781
- for (const phase of task.data.phases) {
782
- const record = yield* (yield* PhaseService).show(
783
- id,
784
- phase.id,
785
- root,
786
- )
787
- if (record.data.claim?.state === "active") {
788
- return yield* new ArchiveError({
789
- message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
790
- })
791
- }
792
- phaseRecords.push(record)
793
- executionUnits.push({ taskId: id, phaseId: phase.id })
794
- }
795
- } else {
796
- executionUnits.push({ taskId: id })
797
- }
1204
+ const executionUnits = archiveContext.executionUnits
1205
+ const phaseRecords = archiveContext.phases
798
1206
  const removedWorktrees: string[] = []
799
1207
  for (const unit of executionUnits)
800
1208
  removedWorktrees.push(
801
- ...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
802
- dryRun: true,
803
- })),
1209
+ ...(yield* worktrees.remove(
1210
+ unit.taskId,
1211
+ executionPhaseId(unit),
1212
+ root,
1213
+ {
1214
+ dryRun: true,
1215
+ },
1216
+ )),
804
1217
  )
805
1218
  const at = new Date().toISOString()
806
1219
  const writes: (Write & { create?: boolean })[] = []
@@ -847,10 +1260,15 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
847
1260
  try {
848
1261
  for (const unit of executionUnits)
849
1262
  await runWorktreeEffect(
850
- worktrees.remove(unit.taskId, unit.phaseId, root, {
851
- snapshots,
852
- lockHeld: true,
853
- }),
1263
+ worktrees.remove(
1264
+ unit.taskId,
1265
+ executionPhaseId(unit),
1266
+ root,
1267
+ {
1268
+ snapshots,
1269
+ lockHeld: true,
1270
+ },
1271
+ ),
854
1272
  )
855
1273
  } catch (cause) {
856
1274
  await restoreWorktreeSnapshots(snapshots)
@@ -973,17 +973,34 @@ export class ContextService extends Effect.Service<ContextService>()(
973
973
  ...(yield* inspectCheckout(repositoryPath, checkoutPath)),
974
974
  })
975
975
  }
976
- const reviewSourceCommit = reviewData
977
- ? yield* runGit(fs, join(root, "repos", reviewData.repo), [
978
- "ls-remote",
979
- "origin",
980
- reviewData.source.kind === "pull-request"
981
- ? reviewData.source.fetchRef
982
- : reviewData.source.ref
983
- .replace(/^refs\/remotes\/origin\//, "")
984
- .replace(/^origin\//, ""),
985
- ])
976
+ const reviewRepository = reviewData
977
+ ? repositories.get(reviewData.repo)
986
978
  : null
979
+ const reviewSourceCommit =
980
+ reviewData && reviewRepository?.remote
981
+ ? yield* fs
982
+ .runCommand(
983
+ [
984
+ "git",
985
+ "ls-remote",
986
+ reviewRepository.remote,
987
+ reviewData.source.kind === "pull-request"
988
+ ? reviewData.source.fetchRef
989
+ : reviewData.source.ref
990
+ .replace(/^refs\/remotes\/origin\//, "")
991
+ .replace(/^origin\//, ""),
992
+ ],
993
+ { captureOutput: true },
994
+ )
995
+ .pipe(
996
+ Effect.map((result) =>
997
+ result.exitCode === 0
998
+ ? result.stdout.trim() || null
999
+ : null,
1000
+ ),
1001
+ Effect.catchAll(() => Effect.succeed(null)),
1002
+ )
1003
+ : null
987
1004
 
988
1005
  const checkoutStates = [writable, ...referenceCheckouts].filter(
989
1006
  (value): value is NonNullable<typeof value> => value !== null,
@@ -396,17 +396,12 @@ export class DoctorService extends Effect.Service<DoctorService>()(
396
396
  for (const source of reviewSources.filter(
397
397
  (item) => item.repo === repository.alias,
398
398
  )) {
399
- const observed = yield* fs.runCommand(
400
- [
401
- "git",
402
- "-C",
403
- repository.path,
404
- "ls-remote",
405
- "origin",
406
- source.ref,
407
- ],
408
- { captureOutput: true },
409
- )
399
+ const observed = repository.remote
400
+ ? yield* fs.runCommand(
401
+ ["git", "ls-remote", repository.remote, source.ref],
402
+ { captureOutput: true },
403
+ )
404
+ : { exitCode: -1, stdout: "", stderr: "origin unavailable" }
410
405
  const available =
411
406
  observed.exitCode === 0 && Boolean(observed.stdout.trim())
412
407
  add({