@knpkv/codecommit-core 0.14.0 → 0.16.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 (135) hide show
  1. package/README.md +7 -3
  2. package/dist/CacheService/Database.d.ts +1 -1
  3. package/dist/CacheService/Database.d.ts.map +1 -1
  4. package/dist/CacheService/Database.js +12 -2
  5. package/dist/CacheService/Database.js.map +1 -1
  6. package/dist/CacheService/diff.d.ts +5 -2
  7. package/dist/CacheService/diff.d.ts.map +1 -1
  8. package/dist/CacheService/diff.js +31 -13
  9. package/dist/CacheService/diff.js.map +1 -1
  10. package/dist/CacheService/migrations/0017_sandbox_region.d.ts +5 -0
  11. package/dist/CacheService/migrations/0017_sandbox_region.d.ts.map +1 -0
  12. package/dist/CacheService/migrations/0017_sandbox_region.js +4 -0
  13. package/dist/CacheService/migrations/0017_sandbox_region.js.map +1 -0
  14. package/dist/CacheService/migrations/0018_pull_request_coordinates.d.ts +6 -0
  15. package/dist/CacheService/migrations/0018_pull_request_coordinates.d.ts.map +1 -0
  16. package/dist/CacheService/migrations/0018_pull_request_coordinates.js +82 -0
  17. package/dist/CacheService/migrations/0018_pull_request_coordinates.js.map +1 -0
  18. package/dist/CacheService/migrations/0019_dependent_pr_coordinates.d.ts +6 -0
  19. package/dist/CacheService/migrations/0019_dependent_pr_coordinates.d.ts.map +1 -0
  20. package/dist/CacheService/migrations/0019_dependent_pr_coordinates.js +57 -0
  21. package/dist/CacheService/migrations/0019_dependent_pr_coordinates.js.map +1 -0
  22. package/dist/CacheService/migrations/0020_notification_coordinates.d.ts +6 -0
  23. package/dist/CacheService/migrations/0020_notification_coordinates.d.ts.map +1 -0
  24. package/dist/CacheService/migrations/0020_notification_coordinates.js +22 -0
  25. package/dist/CacheService/migrations/0020_notification_coordinates.js.map +1 -0
  26. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.d.ts +6 -0
  27. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.d.ts.map +1 -0
  28. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.js +5 -0
  29. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.js.map +1 -0
  30. package/dist/CacheService/repos/CommentRepo.d.ts +6 -2
  31. package/dist/CacheService/repos/CommentRepo.d.ts.map +1 -1
  32. package/dist/CacheService/repos/CommentRepo.js +46 -7
  33. package/dist/CacheService/repos/CommentRepo.js.map +1 -1
  34. package/dist/CacheService/repos/NotificationRepo.d.ts +2 -0
  35. package/dist/CacheService/repos/NotificationRepo.d.ts.map +1 -1
  36. package/dist/CacheService/repos/NotificationRepo.js +10 -3
  37. package/dist/CacheService/repos/NotificationRepo.js.map +1 -1
  38. package/dist/CacheService/repos/PullRequestRepo/index.d.ts +42 -7
  39. package/dist/CacheService/repos/PullRequestRepo/index.d.ts.map +1 -1
  40. package/dist/CacheService/repos/PullRequestRepo/index.js +2 -0
  41. package/dist/CacheService/repos/PullRequestRepo/index.js.map +1 -1
  42. package/dist/CacheService/repos/PullRequestRepo/mutations.d.ts +9 -5
  43. package/dist/CacheService/repos/PullRequestRepo/mutations.d.ts.map +1 -1
  44. package/dist/CacheService/repos/PullRequestRepo/mutations.js +61 -17
  45. package/dist/CacheService/repos/PullRequestRepo/mutations.js.map +1 -1
  46. package/dist/CacheService/repos/PullRequestRepo/queries.d.ts +48 -4
  47. package/dist/CacheService/repos/PullRequestRepo/queries.d.ts.map +1 -1
  48. package/dist/CacheService/repos/PullRequestRepo/queries.js +40 -5
  49. package/dist/CacheService/repos/PullRequestRepo/queries.js.map +1 -1
  50. package/dist/CacheService/repos/SandboxRepo.d.ts +53 -1
  51. package/dist/CacheService/repos/SandboxRepo.d.ts.map +1 -1
  52. package/dist/CacheService/repos/SandboxRepo.js +55 -8
  53. package/dist/CacheService/repos/SandboxRepo.js.map +1 -1
  54. package/dist/CacheService/repos/StatsRepo/index.d.ts +3 -0
  55. package/dist/CacheService/repos/StatsRepo/index.d.ts.map +1 -1
  56. package/dist/CacheService/repos/StatsRepo/internal.d.ts +6 -0
  57. package/dist/CacheService/repos/StatsRepo/internal.d.ts.map +1 -1
  58. package/dist/CacheService/repos/StatsRepo/internal.js +5 -5
  59. package/dist/CacheService/repos/StatsRepo/internal.js.map +1 -1
  60. package/dist/CacheService/repos/StatsRepo/queries.d.ts +1 -0
  61. package/dist/CacheService/repos/StatsRepo/queries.d.ts.map +1 -1
  62. package/dist/CacheService/repos/StatsRepo/queries.js +5 -4
  63. package/dist/CacheService/repos/StatsRepo/queries.js.map +1 -1
  64. package/dist/CacheService/repos/StatsRepo/reviewerData.d.ts +2 -0
  65. package/dist/CacheService/repos/StatsRepo/reviewerData.d.ts.map +1 -1
  66. package/dist/CacheService/repos/StatsRepo/reviewerData.js +38 -15
  67. package/dist/CacheService/repos/StatsRepo/reviewerData.js.map +1 -1
  68. package/dist/CacheService/repos/SubscriptionRepo.d.ts +11 -5
  69. package/dist/CacheService/repos/SubscriptionRepo.d.ts.map +1 -1
  70. package/dist/CacheService/repos/SubscriptionRepo.js +106 -12
  71. package/dist/CacheService/repos/SubscriptionRepo.js.map +1 -1
  72. package/dist/ChildEnv.d.ts.map +1 -1
  73. package/dist/ChildEnv.js +1 -0
  74. package/dist/ChildEnv.js.map +1 -1
  75. package/dist/ConfigService/index.d.ts +1 -1
  76. package/dist/ConfigService/index.d.ts.map +1 -1
  77. package/dist/ConfigService/index.js +1 -1
  78. package/dist/ConfigService/index.js.map +1 -1
  79. package/dist/ConfigService/internal.d.ts +3 -3
  80. package/dist/ConfigService/load.d.ts +3 -3
  81. package/dist/ConfigService/reset.d.ts +3 -3
  82. package/dist/ConfigService/save.d.ts +3 -3
  83. package/dist/Domain.d.ts +1 -0
  84. package/dist/Domain.d.ts.map +1 -1
  85. package/dist/Domain.js +1 -0
  86. package/dist/Domain.js.map +1 -1
  87. package/dist/PRService/index.d.ts +15 -8
  88. package/dist/PRService/index.d.ts.map +1 -1
  89. package/dist/PRService/index.js +5 -5
  90. package/dist/PRService/index.js.map +1 -1
  91. package/dist/PRService/refreshDiffs.d.ts.map +1 -1
  92. package/dist/PRService/refreshDiffs.js +12 -3
  93. package/dist/PRService/refreshDiffs.js.map +1 -1
  94. package/dist/PRService/refreshEnrich.d.ts.map +1 -1
  95. package/dist/PRService/refreshEnrich.js +28 -11
  96. package/dist/PRService/refreshEnrich.js.map +1 -1
  97. package/dist/PRService/refreshFetch.d.ts.map +1 -1
  98. package/dist/PRService/refreshFetch.js +25 -13
  99. package/dist/PRService/refreshFetch.js.map +1 -1
  100. package/dist/PRService/refreshHistory.d.ts +1 -1
  101. package/dist/PRService/refreshHistory.d.ts.map +1 -1
  102. package/dist/PRService/refreshHistory.js +6 -4
  103. package/dist/PRService/refreshHistory.js.map +1 -1
  104. package/dist/PRService/refreshResolve.d.ts +4 -1
  105. package/dist/PRService/refreshResolve.d.ts.map +1 -1
  106. package/dist/PRService/refreshResolve.js +19 -6
  107. package/dist/PRService/refreshResolve.js.map +1 -1
  108. package/dist/PRService/refreshScore.d.ts.map +1 -1
  109. package/dist/PRService/refreshScore.js +4 -1
  110. package/dist/PRService/refreshScore.js.map +1 -1
  111. package/dist/PRService/refreshSinglePR.d.ts +9 -2
  112. package/dist/PRService/refreshSinglePR.d.ts.map +1 -1
  113. package/dist/PRService/refreshSinglePR.js +99 -33
  114. package/dist/PRService/refreshSinglePR.js.map +1 -1
  115. package/dist/ReviewProfile.d.ts +14 -12
  116. package/dist/ReviewProfile.d.ts.map +1 -1
  117. package/dist/ReviewProfile.js +10 -3
  118. package/dist/ReviewProfile.js.map +1 -1
  119. package/dist/SandboxService/DockerService.d.ts +4 -0
  120. package/dist/SandboxService/DockerService.d.ts.map +1 -1
  121. package/dist/SandboxService/DockerService.js +60 -16
  122. package/dist/SandboxService/DockerService.js.map +1 -1
  123. package/dist/SandboxService/SandboxService.d.ts +8 -0
  124. package/dist/SandboxService/SandboxService.d.ts.map +1 -1
  125. package/dist/SandboxService/SandboxService.js +459 -77
  126. package/dist/SandboxService/SandboxService.js.map +1 -1
  127. package/dist/SandboxService/SandboxWorkerScope.d.ts +6 -1
  128. package/dist/SandboxService/SandboxWorkerScope.d.ts.map +1 -1
  129. package/dist/SandboxService/SandboxWorkerScope.js +6 -2
  130. package/dist/SandboxService/SandboxWorkerScope.js.map +1 -1
  131. package/dist/StatsService/WeeklyStats.d.ts +7 -0
  132. package/dist/StatsService/WeeklyStats.d.ts.map +1 -1
  133. package/dist/StatsService/WeeklyStats.js +3 -0
  134. package/dist/StatsService/WeeklyStats.js.map +1 -1
  135. package/package.json +1 -1
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- import { Clock, Config, Context, Crypto, Duration, Effect, Layer, Option, Predicate, Random, Result, Schedule, Stream } from "effect";
9
+ import { Clock, Config, Context, Crypto, Duration, Effect, Fiber, Layer, Option, Predicate, Random, Ref, Result, Schedule, Semaphore, Stream } from "effect";
10
10
  import * as FileSystem from "effect/FileSystem";
11
11
  import { ChildProcess } from "effect/unstable/process";
12
12
  import { SandboxRepo } from "../CacheService/repos/SandboxRepo.js";
@@ -14,7 +14,7 @@ import * as ChildEnv from "../ChildEnv.js";
14
14
  import { ConfigService, defaultSandboxConfig, validateSandboxConfig } from "../ConfigService/index.js";
15
15
  import { PullRequestId, RepositoryName, SandboxId } from "../Domain.js";
16
16
  import { SandboxError } from "../Errors.js";
17
- import { DockerService } from "./DockerService.js";
17
+ import { DockerService, isAlreadyStoppedContainerError, isMissingContainerError } from "./DockerService.js";
18
18
  import { PluginService } from "./PluginService.js";
19
19
  import { SandboxWorkerScope } from "./SandboxWorkerScope.js";
20
20
  /** Keep the container non-root while matching the bind-mounted workspace owner when available. */
@@ -26,6 +26,14 @@ export const sandboxContainerIdentityForWorkspaceOwner = (uid, gid) => uid === 0
26
26
  const SANDBOX_BASE_PORT = 18080;
27
27
  export const sandboxRuntimeHome = "/tmp";
28
28
  export const sandboxRuntimeXdgDataHome = `${sandboxRuntimeHome}/.local/share`;
29
+ const isRegionlessSandbox = (row) => row.region === undefined || row.region === null || row.region === "";
30
+ const isPreContainerSandboxStatus = (status) => status === "creating" || status === "cloning" || status === "starting";
31
+ const isTerminalSandboxStatus = (status) => status === "stopped" || status === "error";
32
+ const isConfirmedStoppedContainer = (state) => state === "exited" || state === "dead";
33
+ const isCompletedLegacyRetirement = (row) => row.accessPassword !== null && row.status === "stopped" && row.legacyRetiredAt !== null;
34
+ const isPendingLegacyRetirement = (row) => row.accessPassword !== null && row.status !== "stopped" && row.legacyRetiredAt !== null;
35
+ const isOrdinaryStoppingSandbox = (row) => row.status === "stopping" && row.legacyRetiredAt === null;
36
+ const isDiscoveredAwsAccountId = (value) => /^\d{12}$/u.test(value);
29
37
  const homeDir = Config.string("HOME").pipe(Config.orElse(() => Config.string("USERPROFILE")));
30
38
  const sandboxesDir = homeDir.pipe(Config.map((h) => `${h}/.codecommit/sandboxes`));
31
39
  const expandHome = (p, home) => p.startsWith("~/") ? `${home}${p.slice(1)}` : p;
@@ -64,13 +72,101 @@ const makeSandboxService = Effect.gen(function* () {
64
72
  const cryptoService = yield* Crypto.Crypto;
65
73
  const homePath = yield* homeDir.pipe(Effect.orDie);
66
74
  const basePath = yield* sandboxesDir.pipe(Effect.orDie);
75
+ const activeWorkerIds = yield* Ref.make(new Map());
76
+ const lifecycleAdmission = yield* Semaphore.make(1);
77
+ const containerAdmission = yield* Semaphore.make(1);
78
+ const createAdmission = yield* Semaphore.make(1);
79
+ const stopRequestedIds = yield* Ref.make(new Set());
80
+ const stopOwnerIds = yield* Ref.make(new Set());
81
+ const retiredLegacyIds = yield* Ref.make(new Set());
82
+ const markWorkerActive = (id) => Ref.update(activeWorkerIds, (workers) => {
83
+ const next = new Map(workers);
84
+ next.set(id, (next.get(id) ?? 0) + 1);
85
+ return next;
86
+ });
87
+ const releaseWorker = (id) => Ref.modify(activeWorkerIds, (workers) => {
88
+ const next = new Map(workers);
89
+ const count = next.get(id) ?? 0;
90
+ if (count <= 1)
91
+ next.delete(id);
92
+ else
93
+ next.set(id, count - 1);
94
+ return [count <= 1, next];
95
+ });
96
+ const hasActiveWorker = (id) => Ref.get(activeWorkerIds).pipe(Effect.map((workers) => (workers.get(id) ?? 0) > 0));
97
+ const isStopRequested = (id) => Ref.get(stopRequestedIds).pipe(Effect.map((ids) => ids.has(id)));
98
+ const requestWorkerStops = (ids) => Ref.update(stopRequestedIds, (requested) => new Set([...requested, ...ids]));
99
+ const claimStopOwners = (ids) => Ref.update(stopOwnerIds, (owners) => new Set([...owners, ...ids]));
100
+ const isStopOwned = (id) => Ref.get(stopOwnerIds).pipe(Effect.map((ids) => ids.has(id)));
101
+ const clearWorkerStopRequest = (id) => Ref.update(stopRequestedIds, (ids) => {
102
+ if (!ids.has(id))
103
+ return ids;
104
+ const next = new Set(ids);
105
+ next.delete(id);
106
+ return next;
107
+ });
108
+ const findFallbackCandidates = (params) => !isDiscoveredAwsAccountId(params.awsAccountId)
109
+ ? Effect.gen(function* () {
110
+ const candidates = (yield* repo.findAll()).filter((row) => isDiscoveredAwsAccountId(row.awsAccountId) &&
111
+ row.awsAccountId !== params.profile &&
112
+ row.pullRequestId === params.pullRequestId &&
113
+ row.repositoryName === params.repositoryName &&
114
+ (row.region === params.region || row.region === null || row.region === undefined || row.region === ""));
115
+ const terminalCandidates = [];
116
+ for (const candidate of candidates) {
117
+ if (!isTerminalSandboxStatus(candidate.status)) {
118
+ return [{ row: candidate, blocksCreation: true }];
119
+ }
120
+ const containers = yield* docker.listContainersByLabel("codecommit.sandbox.id", candidate.id);
121
+ if (containers.some((container) => !isConfirmedStoppedContainer(container.State))) {
122
+ return [{ row: candidate, blocksCreation: true }];
123
+ }
124
+ terminalCandidates.push(candidate);
125
+ }
126
+ return terminalCandidates.map((row) => ({ row, blocksCreation: false }));
127
+ })
128
+ : Effect.succeed([]);
67
129
  const loadSandboxConfig = configService.load.pipe(Effect.map((config) => config.sandbox), Effect.catch(() => Effect.succeed(defaultSandboxConfig)));
68
130
  const updateStatus = (id, status, extra) => repo.updateStatus(id, status, extra);
69
- const recordCreationFailure = (id, error) => Effect.gen(function* () {
131
+ const updateWorkerStatus = (id, status, extra) => lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
132
+ if (yield* isStopRequested(String(id)))
133
+ return;
134
+ yield* updateStatus(id, status, extra);
135
+ }));
136
+ const releaseWorkerReservation = (id) => lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
137
+ const lastWorker = yield* releaseWorker(id);
138
+ if (lastWorker && !(yield* isStopOwned(id))) {
139
+ if (yield* isStopRequested(id)) {
140
+ yield* updateStatus(SandboxId.make(id), "stopped").pipe(Effect.catch((error) => Effect.logError(`Failed to finalize stopped sandbox ${id}`, error)));
141
+ }
142
+ yield* clearWorkerStopRequest(id);
143
+ }
144
+ }));
145
+ const releaseStopOwner = (id, finalizeUnowned = false) => lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
146
+ const hadStopRequest = yield* isStopRequested(id);
147
+ yield* Ref.update(stopOwnerIds, (ids) => {
148
+ if (!ids.has(id))
149
+ return ids;
150
+ const next = new Set(ids);
151
+ next.delete(id);
152
+ return next;
153
+ });
154
+ if (!(yield* hasActiveWorker(id))) {
155
+ if (finalizeUnowned && hadStopRequest) {
156
+ yield* repo.findById(SandboxId.make(id)).pipe(Effect.flatMap((row) => (row.containerId === null || row.containerId.length === 0) && isPreContainerSandboxStatus(row.status)
157
+ ? updateStatus(SandboxId.make(id), "stopped")
158
+ : Effect.void), Effect.catch((error) => Effect.logError(`Failed to finalize stopped sandbox ${id}`, error)));
159
+ }
160
+ yield* clearWorkerStopRequest(id);
161
+ }
162
+ }));
163
+ const recordCreationFailure = (id, error) => lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
164
+ if (yield* isStopRequested(String(id)))
165
+ return;
70
166
  yield* Effect.logError(`Sandbox ${id} creation failed`, error);
71
167
  const errorDetail = Result.try(() => String(Predicate.isError(error) ? error.message : error)).pipe(Result.getOrElse(() => "Unknown error"));
72
168
  yield* updateStatus(id, "error", { error: errorDetail.slice(0, 500) }).pipe(Effect.catch((statusError) => Effect.logError("Failed to update sandbox error status", statusError)), Effect.catchDefect((statusDefect) => Effect.logError("Defect while updating sandbox error status", statusDefect)));
73
- });
169
+ }));
74
170
  const progress = (id, detail) => Clock.currentTimeMillis.pipe(Effect.flatMap((ms) => {
75
171
  const ts = new Date(ms).toISOString();
76
172
  return repo.updateDetail(id, detail).pipe(Effect.tap(() => repo.appendLog(id, `[${ts}] ${detail}`)), Effect.tap(() => Effect.logInfo(`Sandbox ${id}: ${detail}`)));
@@ -87,12 +183,141 @@ const makeSandboxService = Effect.gen(function* () {
87
183
  sourceBranch: row.sourceBranch
88
184
  }
89
185
  });
186
+ const retireLegacySandbox = (legacy) => lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
187
+ const activeWorker = yield* hasActiveWorker(legacy.id);
188
+ if (activeWorker) {
189
+ return yield* new SandboxError({
190
+ sandboxId: SandboxId.make(legacy.id),
191
+ message: "Legacy sandbox is still active; retry after its worker exits"
192
+ });
193
+ }
194
+ const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", legacy.id);
195
+ const containerIds = new Set([
196
+ ...(legacy.containerId === null || legacy.containerId.length === 0 ? [] : [legacy.containerId]),
197
+ ...discovered.map((container) => container.Id)
198
+ ]);
199
+ if (legacy.accessPassword === null) {
200
+ yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
201
+ yield* updateStatus(SandboxId.make(legacy.id), "error", {
202
+ error: "Legacy unauthenticated sandbox stopped; delete and recreate it"
203
+ });
204
+ return;
205
+ }
206
+ yield* markLegacyRetirementStarted(SandboxId.make(legacy.id));
207
+ yield* Effect.forEach(containerIds, (containerId) => docker.inspectContainer(containerId).pipe(Effect.flatMap((info) => info.State.Running ? docker.stopContainer(containerId) : Effect.void), Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
208
+ yield* Clock.currentTimeMillis.pipe(Effect.flatMap((ms) => updateStatus(SandboxId.make(legacy.id), "stopped", { legacyRetiredAt: new Date(ms).toISOString() })));
209
+ yield* Ref.update(retiredLegacyIds, (ids) => new Set(ids).add(legacy.id));
210
+ }));
211
+ const markLegacyRetired = (id) => Clock.currentTimeMillis.pipe(Effect.flatMap((ms) => updateStatus(id, "stopped", { legacyRetiredAt: new Date(ms).toISOString() })));
212
+ const markLegacyRetirementStarted = (id) => Clock.currentTimeMillis.pipe(Effect.flatMap((ms) => updateStatus(id, "stopping", { legacyRetiredAt: new Date(ms).toISOString() })));
90
213
  const service = {
91
214
  create: (params) => Effect.gen(function* () {
215
+ yield* Effect.uninterruptible(createAdmission.take(1));
92
216
  // Singleton check — one active sandbox per PR
93
- const existing = yield* repo.findByPr(params.awsAccountId, params.pullRequestId);
94
- if (Option.isSome(existing)) {
95
- return existing.value;
217
+ const existing = yield* repo.findByPr(params.awsAccountId, params.pullRequestId, params.repositoryName, params.region);
218
+ const exactCandidate = Option.isSome(existing) && existing.value.region === params.region
219
+ ? existing.value
220
+ : undefined;
221
+ const exactExisting = exactCandidate !== undefined &&
222
+ !isCompletedLegacyRetirement(exactCandidate) &&
223
+ !isPendingLegacyRetirement(exactCandidate)
224
+ ? exactCandidate
225
+ : undefined;
226
+ const pendingExact = exactCandidate !== undefined && isPendingLegacyRetirement(exactCandidate)
227
+ ? [exactCandidate]
228
+ : [];
229
+ const emptyAccountRows = params.awsAccountId.length === 0
230
+ ? []
231
+ : (yield* repo.findAll()).filter((row) => row.awsAccountId === "" &&
232
+ row.pullRequestId === params.pullRequestId &&
233
+ row.repositoryName === params.repositoryName &&
234
+ row.region === params.region);
235
+ const profileRows = isDiscoveredAwsAccountId(params.awsAccountId) &&
236
+ !isDiscoveredAwsAccountId(params.profile) &&
237
+ params.profile !== params.awsAccountId
238
+ ? (yield* repo.findAll()).filter((row) => row.awsAccountId === params.profile &&
239
+ row.pullRequestId === params.pullRequestId &&
240
+ row.repositoryName === params.repositoryName &&
241
+ row.region === params.region)
242
+ : [];
243
+ const effectiveExisting = yield* Effect.forEach([...emptyAccountRows, ...profileRows, ...pendingExact]
244
+ .filter((row) => !isCompletedLegacyRetirement(row)), retireLegacySandbox, { discard: true }).pipe(Effect.as(exactExisting));
245
+ const regionlessKeys = Array.from(new Set([
246
+ params.awsAccountId,
247
+ isDiscoveredAwsAccountId(params.awsAccountId)
248
+ ? params.profile
249
+ : undefined
250
+ ])).filter((key) => key !== undefined && key.length > 0);
251
+ const regionlessCandidates = (yield* Effect.forEach(regionlessKeys, (key) => repo.findRegionlessByPrAll(key, params.pullRequestId, params.repositoryName), { concurrency: 1 })).flat()
252
+ .filter((row) => regionlessKeys.includes(row.awsAccountId) && !isCompletedLegacyRetirement(row));
253
+ const regionless = regionlessCandidates.filter((row, index, candidates) => candidates.findIndex((candidate) => candidate.id === row.id) === index);
254
+ const ambiguousNumericProfile = isDiscoveredAwsAccountId(params.profile)
255
+ ? regionless.find((row) => row.awsAccountId === params.profile)
256
+ : undefined;
257
+ if (ambiguousNumericProfile !== undefined) {
258
+ return yield* new SandboxError({
259
+ sandboxId: SandboxId.make(ambiguousNumericProfile.id),
260
+ message: "Configured numeric profile matches an existing regionless sandbox; account identity is ambiguous"
261
+ });
262
+ }
263
+ const legacyResults = yield* Effect.forEach(regionless, (legacy) => lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
264
+ if (legacy.accessPassword !== null && (yield* hasActiveWorker(legacy.id))) {
265
+ return yield* new SandboxError({
266
+ sandboxId: SandboxId.make(legacy.id),
267
+ message: "Regionless legacy sandbox is still active; retry after its worker exits"
268
+ });
269
+ }
270
+ const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", legacy.id);
271
+ const containerIds = new Set([
272
+ ...(legacy.containerId === null || legacy.containerId.length === 0 ? [] : [legacy.containerId]),
273
+ ...discovered.map((container) => container.Id)
274
+ ]);
275
+ if (legacy.accessPassword !== null && containerIds.size === 0 && isPreContainerSandboxStatus(legacy.status)) {
276
+ return yield* new SandboxError({
277
+ sandboxId: SandboxId.make(legacy.id),
278
+ message: "Regionless legacy sandbox is still starting; retry after its worker reports a container"
279
+ });
280
+ }
281
+ if (legacy.accessPassword !== null) {
282
+ yield* markLegacyRetirementStarted(SandboxId.make(legacy.id));
283
+ }
284
+ if (legacy.accessPassword === null) {
285
+ yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
286
+ yield* updateStatus(SandboxId.make(legacy.id), "error", {
287
+ error: "Legacy unauthenticated sandbox stopped; delete and recreate it"
288
+ });
289
+ return Option.some(legacy);
290
+ }
291
+ yield* Effect.forEach(containerIds, (containerId) => docker.inspectContainer(containerId).pipe(Effect.flatMap((info) => info.State.Running ? docker.stopContainer(containerId) : Effect.void), Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
292
+ yield* markLegacyRetired(SandboxId.make(legacy.id));
293
+ return Option.none();
294
+ })), { concurrency: 1 });
295
+ const unauthenticated = legacyResults.find(Option.isSome);
296
+ if (unauthenticated !== undefined && Option.isSome(unauthenticated)) {
297
+ return yield* new SandboxError({
298
+ sandboxId: SandboxId.make(unauthenticated.value.id),
299
+ message: "Regionless legacy sandbox requires explicit cleanup before recreation"
300
+ });
301
+ }
302
+ if (exactCandidate !== undefined && isOrdinaryStoppingSandbox(exactCandidate)) {
303
+ return yield* new SandboxError({
304
+ sandboxId: SandboxId.make(exactCandidate.id),
305
+ message: "Sandbox is still stopping; retry after it has stopped"
306
+ });
307
+ }
308
+ if (effectiveExisting !== undefined) {
309
+ if (effectiveExisting.containerId === null && isPreContainerSandboxStatus(effectiveExisting.status)) {
310
+ const activeWorker = yield* hasActiveWorker(effectiveExisting.id);
311
+ if (activeWorker) {
312
+ return effectiveExisting;
313
+ }
314
+ yield* updateStatus(SandboxId.make(effectiveExisting.id), "error", {
315
+ error: "Orphaned (no container)"
316
+ });
317
+ }
318
+ else {
319
+ return effectiveExisting;
320
+ }
96
321
  }
97
322
  const sandboxCfg = yield* loadSandboxConfig;
98
323
  yield* validateSandboxConfig(sandboxCfg, homePath);
@@ -103,26 +328,17 @@ const makeSandboxService = Effect.gen(function* () {
103
328
  const accessPassword = yield* cryptoService.randomUUIDv4;
104
329
  const workspacePath = `${basePath}/${id}`;
105
330
  const now = new Date(nowMs).toISOString();
106
- yield* repo.insert({
107
- id,
108
- pullRequestId: params.pullRequestId,
109
- awsAccountId: params.awsAccountId,
110
- repositoryName: params.repositoryName,
111
- sourceBranch: params.sourceBranch,
112
- accessPassword,
113
- workspacePath,
114
- status: "creating",
115
- createdAt: now,
116
- lastActivityAt: now
117
- });
118
- // Fork daemon for async lifecycle
119
- yield* ownerScope.fork(Effect.gen(function* () {
331
+ const workerTransferred = yield* Ref.make(false);
332
+ const releaseWorkerReservationForCreate = () => releaseWorkerReservation(String(id));
333
+ const worker = Effect.gen(function* () {
120
334
  const fs = yield* FileSystem.FileSystem;
121
335
  const host = yield* ChildEnv.HostEnvironment;
122
336
  const log = (detail) => progress(id, detail);
337
+ if (yield* isStopRequested(String(id)))
338
+ return;
123
339
  yield* log("Sandbox config validated");
124
340
  // Clone via HTTPS + AWS credential helper
125
- yield* updateStatus(id, "cloning");
341
+ yield* updateWorkerStatus(id, "cloning");
126
342
  yield* fs.makeDirectory(workspacePath, { recursive: true });
127
343
  const cloneUrl = `https://git-codecommit.${params.region}.amazonaws.com/v1/repos/${params.repositoryName}`;
128
344
  const branch = params.sourceBranch.replace(/^refs\/heads\//, "");
@@ -163,12 +379,14 @@ const makeSandboxService = Effect.gen(function* () {
163
379
  if (cloneResult.exitCode !== 0) {
164
380
  const stderrText = cloneResult.stderrText;
165
381
  yield* log(`Clone failed: ${stderrText}`);
166
- yield* updateStatus(id, "error", {
382
+ yield* updateWorkerStatus(id, "error", {
167
383
  error: stderrText || `git clone failed (exit ${cloneResult.exitCode})`
168
384
  });
169
385
  return;
170
386
  }
171
387
  yield* log("Clone complete");
388
+ if (yield* isStopRequested(String(id)))
389
+ return;
172
390
  const workspaceInfo = yield* fs.stat(workspacePath);
173
391
  const containerIdentity = sandboxContainerIdentityForWorkspaceOwner(Option.getOrUndefined(workspaceInfo.uid), Option.getOrUndefined(workspaceInfo.gid));
174
392
  if (containerIdentity.repairRootOwnedWorkspace) {
@@ -182,22 +400,40 @@ const makeSandboxService = Effect.gen(function* () {
182
400
  }
183
401
  }
184
402
  // Pull image
185
- yield* updateStatus(id, "starting");
403
+ yield* updateWorkerStatus(id, "starting");
186
404
  yield* log(`Pulling image ${sandboxCfg.image}`);
187
405
  yield* docker.pullImage(sandboxCfg.image).pipe(Effect.tap(() => log("Image ready")), Effect.catchIf(() => true, () => log("Image pull skipped (using cached)")));
188
- // Create + start container
189
- yield* log("Creating container");
190
- yield* validateSandboxConfig(sandboxCfg, homePath);
191
- const containerConfig = makeContainerConfig(workspacePath, port, id, params.pullRequestId, sandboxCfg, homePath, containerIdentity.user, accessPassword);
192
- const containerId = yield* docker.createContainer(containerConfig);
193
- const cid = containerId.trim();
194
- yield* log(`Container ${cid.slice(0, 12)} created, starting`);
195
- yield* docker.startContainer(cid);
196
- yield* updateStatus(id, "starting", { containerId: cid, port });
197
- yield* log(`Container started on port ${port}`);
406
+ // Create + start container. Stop and provisioning share this gate so a
407
+ // stop request cannot pass while a worker is between create and start.
408
+ const cid = yield* lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
409
+ if (yield* isStopRequested(String(id)))
410
+ return undefined;
411
+ yield* log("Creating container");
412
+ yield* validateSandboxConfig(sandboxCfg, homePath);
413
+ const containerConfig = makeContainerConfig(workspacePath, port, id, params.pullRequestId, sandboxCfg, homePath, containerIdentity.user, accessPassword);
414
+ const containerId = yield* docker.createContainer(containerConfig);
415
+ const nextCid = containerId.trim();
416
+ if (yield* isStopRequested(String(id))) {
417
+ yield* updateStatus(id, "stopping", { containerId: nextCid, port });
418
+ return undefined;
419
+ }
420
+ yield* log(`Container ${nextCid.slice(0, 12)} created, starting`);
421
+ yield* docker.startContainer(nextCid);
422
+ yield* updateStatus(id, "starting", { containerId: nextCid, port });
423
+ yield* log(`Container started on port ${port}`);
424
+ if (yield* isStopRequested(String(id)))
425
+ return undefined;
426
+ return nextCid;
427
+ }));
428
+ if (cid === undefined)
429
+ return;
430
+ if (yield* isStopRequested(String(id)))
431
+ return;
198
432
  // Wait for code-server to be ready (poll health)
199
433
  yield* log("Waiting for code-server health check");
200
434
  yield* docker.exec(cid, ["curl", "-sf", "http://localhost:8080/healthz"]).pipe(Effect.retry(Schedule.max([Schedule.recurs(30), Schedule.spaced(Duration.seconds(1))])), Effect.tap(() => log("code-server ready")), Effect.tapError((e) => log(`Health check failed: ${String(e)}`)), Effect.catchIf(() => true, () => Effect.void));
435
+ if (yield* isStopRequested(String(id)))
436
+ return;
201
437
  // Run plugin hooks
202
438
  const row = yield* repo.findById(id);
203
439
  const ctx = makeSandboxContext(row);
@@ -207,62 +443,159 @@ const makeSandboxService = Effect.gen(function* () {
207
443
  // Install configured extensions
208
444
  if (sandboxCfg.extensions.length > 0) {
209
445
  yield* log(`Installing ${sandboxCfg.extensions.length} extension(s)`);
210
- yield* Effect.forEach(sandboxCfg.extensions, (ext) => log(`Installing extension: ${ext}`).pipe(Effect.andThen(docker.exec(cid, ["code-server", "--install-extension", ext])), Effect.tap((output) => log(`Extension installed: ${ext}${output ? `\n${output.trim()}` : ""}`)), Effect.tapError((e) => log(`Extension failed: ${ext} — ${String(e)}`)), Effect.catchIf(() => true, () => Effect.void)), { discard: true });
446
+ yield* Effect.forEach(sandboxCfg.extensions, (ext) => log(`Installing extension: ${ext}`).pipe(Effect.andThen(docker.exec(cid, ["code-server", "--install-extension", ext])), Effect.tap((output) => log(`Extension installed: ${ext}${output.length > 0 ? `\n${output.trim()}` : ""}`)), Effect.tapError((e) => log(`Extension failed: ${ext} — ${String(e)}`)), Effect.catchIf(() => true, () => Effect.void)), { discard: true });
211
447
  }
212
448
  // Run configured setup commands
213
449
  if (sandboxCfg.setupCommands.length > 0) {
214
450
  yield* log(`Running ${sandboxCfg.setupCommands.length} setup command(s)`);
215
- yield* Effect.forEach(sandboxCfg.setupCommands, (cmd, i) => log(`[${i + 1}/${sandboxCfg.setupCommands.length}] ${cmd}`).pipe(Effect.andThen(docker.exec(cid, ["sh", "-c", cmd])), Effect.tap((output) => log(`Command done: ${cmd.slice(0, 60)}${output ? `\n${output.trim()}` : ""}`)), Effect.tapError((e) => log(`Command failed: ${cmd.slice(0, 60)} — ${String(e)}`)), Effect.catchIf(() => true, () => Effect.void)), { discard: true });
451
+ yield* Effect.forEach(sandboxCfg.setupCommands, (cmd, i) => log(`[${i + 1}/${sandboxCfg.setupCommands.length}] ${cmd}`).pipe(Effect.andThen(docker.exec(cid, ["sh", "-c", cmd])), Effect.tap((output) => log(`Command done: ${cmd.slice(0, 60)}${output.length > 0 ? `\n${output.trim()}` : ""}`)), Effect.tapError((e) => log(`Command failed: ${cmd.slice(0, 60)} — ${String(e)}`)), Effect.catchIf(() => true, () => Effect.void)), { discard: true });
216
452
  }
217
- yield* updateStatus(id, "running");
453
+ yield* updateWorkerStatus(id, "running");
218
454
  yield* log("Sandbox ready");
219
455
  }).pipe(Effect.catch((error) => recordCreationFailure(id, error)),
220
456
  // Observe and persist unexpected defects without recovering them.
221
457
  // `tapDefect` leaves the original Cause / Exit unchanged.
222
- Effect.tapDefect((defect) => recordCreationFailure(id, defect))));
458
+ Effect.tapDefect((defect) => recordCreationFailure(id, defect)));
459
+ yield* Effect.acquireUseRelease(lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
460
+ const fallbackCandidates = yield* findFallbackCandidates(params);
461
+ const blockingCandidate = fallbackCandidates.find((candidate) => candidate.blocksCreation);
462
+ if (blockingCandidate !== undefined) {
463
+ return yield* new SandboxError({
464
+ sandboxId: SandboxId.make(blockingCandidate.row.id),
465
+ message: "AWS account identity is unavailable; retry after account discovery"
466
+ });
467
+ }
468
+ const fallbackStopGuards = fallbackCandidates.map((candidate) => candidate.row.id);
469
+ yield* claimStopOwners(fallbackStopGuards);
470
+ yield* requestWorkerStops(fallbackStopGuards);
471
+ return fallbackStopGuards;
472
+ })), () => Effect.gen(function* () {
473
+ yield* markWorkerActive(String(id));
474
+ yield* repo.insert({
475
+ id,
476
+ pullRequestId: params.pullRequestId,
477
+ awsAccountId: params.awsAccountId,
478
+ repositoryName: params.repositoryName,
479
+ region: params.region,
480
+ sourceBranch: params.sourceBranch,
481
+ accessPassword,
482
+ workspacePath,
483
+ status: "creating",
484
+ createdAt: now,
485
+ lastActivityAt: now
486
+ }).pipe(Effect.andThen(Effect.uninterruptible(ownerScope.fork(worker, releaseWorkerReservationForCreate()).pipe(Effect.flatMap(({ fiber, started }) => Effect.race(started.pipe(Effect.as(true)), Fiber.await(fiber).pipe(Effect.as(false)))), Effect.flatMap((started) => started ? Ref.set(workerTransferred, true) : Effect.void)))), Effect.ensuring(Ref.get(workerTransferred).pipe(Effect.flatMap((transferred) => transferred ? Effect.void : releaseWorkerReservationForCreate()))));
487
+ }), (fallbackStopGuards) => Effect.forEach(fallbackStopGuards, (guardId) => releaseStopOwner(guardId), { discard: true }));
223
488
  return yield* repo.findById(id);
224
- }).pipe(Effect.mapError((cause) => new SandboxError({ message: "Failed to create sandbox", cause }))),
489
+ }).pipe(Effect.ensuring(createAdmission.release(1)), Effect.mapError((cause) => Predicate.isTagged(cause, "SandboxError")
490
+ ? cause
491
+ : new SandboxError({ message: "Failed to create sandbox", cause }))),
225
492
  get: (id) => repo.findById(id).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Sandbox not found", cause }))),
226
493
  list: () => repo.findActive(),
227
494
  listAll: () => repo.findAll(),
228
- stop: (id) => Effect.gen(function* () {
229
- const row = yield* repo.findById(id);
230
- yield* updateStatus(id, "stopping");
231
- if (row.containerId) {
232
- const ctx = makeSandboxContext(row);
233
- yield* plugins.executeHook("onSandboxDestroy", ctx);
234
- yield* docker.stopContainer(row.containerId).pipe(Effect.catchIf(() => true, () => Effect.void));
235
- }
236
- yield* updateStatus(id, "stopped");
237
- yield* Effect.logInfo(`Sandbox ${id} stopped`);
495
+ stop: (id) => Effect.uninterruptibleMask((restore) => {
496
+ const stopAdmissionAcquired = Effect.gen(function* () {
497
+ const acquired = yield* Ref.make(false);
498
+ return acquired;
499
+ });
500
+ const stopSandbox = lifecycleAdmission.withPermits(1)(Effect.gen(function* () {
501
+ const row = yield* repo.findById(id);
502
+ yield* updateStatus(id, "stopping");
503
+ if (row.containerId !== null) {
504
+ const containerId = row.containerId;
505
+ const ctx = makeSandboxContext(row);
506
+ yield* plugins.executeHook("onSandboxDestroy", ctx);
507
+ yield* containerAdmission.withPermits(1)(Effect.gen(function* () {
508
+ const stop = row.legacyRetiredAt === null
509
+ ? docker.stopContainer(containerId).pipe(Effect.catchIf(() => true, () => Effect.void))
510
+ : docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void));
511
+ yield* stop;
512
+ }));
513
+ }
514
+ yield* updateStatus(id, "stopped");
515
+ yield* Effect.logInfo(`Sandbox ${id} stopped`);
516
+ }));
517
+ return Effect.flatMap(stopAdmissionAcquired, (admissionAcquired) => Effect.ensuring(Effect.gen(function* () {
518
+ // Publish the stop intent before waiting for create admission.
519
+ // A provisioning worker may hold that permit while it inserts its row.
520
+ yield* claimStopOwners([String(id)]);
521
+ yield* requestWorkerStops([String(id)]);
522
+ yield* restore(createAdmission.withPermits(1)(Effect.uninterruptible(Effect.gen(function* () {
523
+ yield* Ref.set(admissionAcquired, true);
524
+ yield* stopSandbox;
525
+ }))));
526
+ }), Ref.get(admissionAcquired).pipe(Effect.flatMap((acquired) => releaseStopOwner(String(id), !acquired)))));
238
527
  }).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Failed to stop sandbox", cause }))),
239
- restart: (id) => Effect.gen(function* () {
528
+ restart: (id) => Effect.uninterruptibleMask((restore) => Effect.acquireUseRelease(Effect.gen(function* () {
529
+ yield* lifecycleAdmission.take(1);
530
+ return {
531
+ permitReleased: yield* Ref.make(false),
532
+ workerMarked: yield* Ref.make(false)
533
+ };
534
+ }), ({ permitReleased, workerMarked }) => Effect.gen(function* () {
240
535
  const row = yield* repo.findById(id);
536
+ if (yield* isStopRequested(String(id)))
537
+ return;
538
+ const wasRetired = yield* Ref.get(retiredLegacyIds).pipe(Effect.map((ids) => ids.has(String(id))));
539
+ if (wasRetired || row.legacyRetiredAt !== null) {
540
+ return yield* new SandboxError({
541
+ sandboxId: id,
542
+ message: "Legacy sandbox was retired; create a replacement"
543
+ });
544
+ }
241
545
  if (row.accessPassword === null) {
242
- return yield* Effect.fail(new SandboxError({
546
+ return yield* new SandboxError({
243
547
  sandboxId: id,
244
548
  message: "Legacy sandbox has no authenticated access credential; delete and recreate it"
245
- }));
549
+ });
246
550
  }
247
- if (!row.containerId) {
248
- return yield* Effect.fail(new SandboxError({ sandboxId: id, message: "No container to restart" }));
551
+ if (row.containerId === null) {
552
+ return yield* new SandboxError({ sandboxId: id, message: "No container to restart" });
249
553
  }
250
- yield* updateStatus(id, "starting");
251
- yield* progress(id, "Restarting container");
252
- yield* docker.startContainer(row.containerId);
253
- yield* updateStatus(id, "starting", row.port ? { port: row.port } : {});
254
- yield* progress(id, "Waiting for code-server health check");
255
- yield* docker.exec(row.containerId, ["curl", "-sf", "http://localhost:8080/healthz"]).pipe(Effect.retry(Schedule.max([Schedule.recurs(30), Schedule.spaced(Duration.seconds(1))])), Effect.tap(() => progress(id, "code-server ready")), Effect.catchIf(() => true, () => Effect.void));
256
- const ctx = makeSandboxContext(yield* repo.findById(id));
257
- yield* plugins.executeHook("onSandboxReady", ctx);
258
- yield* updateStatus(id, "running");
259
- yield* progress(id, "Sandbox restarted");
260
- yield* Effect.logInfo(`Sandbox ${id} restarted`);
261
- }).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Failed to restart sandbox", cause }))),
554
+ const containerId = row.containerId;
555
+ yield* markWorkerActive(String(id));
556
+ yield* Ref.set(workerMarked, true);
557
+ yield* lifecycleAdmission.release(1);
558
+ yield* Ref.set(permitReleased, true);
559
+ yield* restore(Effect.gen(function* () {
560
+ if (yield* isStopRequested(String(id)))
561
+ return;
562
+ yield* updateWorkerStatus(id, "starting");
563
+ if (yield* isStopRequested(String(id)))
564
+ return;
565
+ yield* progress(id, "Restarting container");
566
+ const started = yield* containerAdmission.withPermits(1)(Effect.gen(function* () {
567
+ if (yield* isStopRequested(String(id)))
568
+ return false;
569
+ yield* docker.startContainer(containerId);
570
+ yield* updateStatus(id, "starting", row.port !== null ? { port: row.port } : {});
571
+ return true;
572
+ }));
573
+ if (!started || (yield* isStopRequested(String(id))))
574
+ return;
575
+ yield* progress(id, "Waiting for code-server health check");
576
+ yield* docker.exec(containerId, ["curl", "-sf", "http://localhost:8080/healthz"]).pipe(Effect.retry(Schedule.max([Schedule.recurs(30), Schedule.spaced(Duration.seconds(1))])), Effect.tap(() => progress(id, "code-server ready")), Effect.catchIf(() => true, () => Effect.void));
577
+ if (yield* isStopRequested(String(id)))
578
+ return;
579
+ const ctx = makeSandboxContext(yield* repo.findById(id));
580
+ yield* plugins.executeHook("onSandboxReady", ctx);
581
+ if (yield* isStopRequested(String(id)))
582
+ return;
583
+ yield* updateWorkerStatus(id, "running");
584
+ if (yield* isStopRequested(String(id)))
585
+ return;
586
+ yield* progress(id, "Sandbox restarted");
587
+ yield* Effect.logInfo(`Sandbox ${id} restarted`);
588
+ }));
589
+ }), ({ permitReleased, workerMarked }) => Effect.gen(function* () {
590
+ if (!(yield* Ref.getAndSet(permitReleased, true)))
591
+ yield* lifecycleAdmission.release(1);
592
+ if (yield* Ref.getAndSet(workerMarked, false))
593
+ yield* releaseWorkerReservation(String(id));
594
+ }))).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Failed to restart sandbox", cause }))),
262
595
  cleanup: (id) => Effect.gen(function* () {
263
596
  const row = yield* repo.findById(id);
264
597
  const fs = yield* FileSystem.FileSystem;
265
- if (row.containerId) {
598
+ if (row.containerId !== null) {
266
599
  yield* docker.removeContainer(row.containerId).pipe(Effect.catchIf(() => true, () => Effect.void));
267
600
  }
268
601
  yield* fs.remove(row.workspacePath, { recursive: true }).pipe(Effect.catchIf(() => true, () => Effect.void));
@@ -270,37 +603,86 @@ const makeSandboxService = Effect.gen(function* () {
270
603
  yield* Effect.logInfo(`Sandbox ${id} cleaned up`);
271
604
  }).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Failed to cleanup sandbox", cause }))),
272
605
  reconcile: () => Effect.gen(function* () {
606
+ yield* Effect.uninterruptible(lifecycleAdmission.take(1));
273
607
  const active = yield* repo.findActive();
274
608
  const all = yield* repo.findAll();
275
609
  const activeIds = new Set(active.map((row) => row.id));
276
- const rows = all.filter((row) => row.accessPassword === null || activeIds.has(row.id));
610
+ const rows = all.filter((row) => !isCompletedLegacyRetirement(row) &&
611
+ (row.accessPassword === null || activeIds.has(row.id) || isRegionlessSandbox(row) ||
612
+ (row.legacyRetiredAt !== null && row.status !== "stopped")));
277
613
  yield* Effect.forEach(rows, (row) => Effect.gen(function* () {
614
+ if (isRegionlessSandbox(row)) {
615
+ const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", row.id);
616
+ const containerIds = new Set([
617
+ ...(row.containerId === null || row.containerId.length === 0 ? [] : [row.containerId]),
618
+ ...discovered.map((container) => container.Id)
619
+ ]);
620
+ if (row.accessPassword !== null &&
621
+ containerIds.size === 0 &&
622
+ isPreContainerSandboxStatus(row.status)) {
623
+ const activeWorker = yield* hasActiveWorker(row.id);
624
+ if (activeWorker)
625
+ return;
626
+ yield* updateStatus(SandboxId.make(row.id), "error", { error: "Orphaned (no container)" });
627
+ return;
628
+ }
629
+ if (row.accessPassword === null) {
630
+ yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
631
+ yield* updateStatus(SandboxId.make(row.id), "error", {
632
+ error: "Legacy unauthenticated sandbox stopped; delete and recreate it"
633
+ });
634
+ return;
635
+ }
636
+ if (yield* hasActiveWorker(row.id)) {
637
+ return;
638
+ }
639
+ yield* markLegacyRetirementStarted(SandboxId.make(row.id));
640
+ yield* Effect.forEach(containerIds, (containerId) => docker.inspectContainer(containerId).pipe(Effect.flatMap((info) => info.State.Running ? docker.stopContainer(containerId) : Effect.void), Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
641
+ yield* markLegacyRetired(SandboxId.make(row.id));
642
+ yield* Effect.logInfo(`Reconciled regionless sandbox ${row.id}`);
643
+ return;
644
+ }
645
+ if (row.accessPassword !== null && row.legacyRetiredAt !== null) {
646
+ const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", row.id);
647
+ const containerIds = new Set([
648
+ ...(row.containerId === null || row.containerId.length === 0 ? [] : [row.containerId]),
649
+ ...discovered.map((container) => container.Id)
650
+ ]);
651
+ yield* Effect.forEach(containerIds, (containerId) => docker.inspectContainer(containerId).pipe(Effect.flatMap((info) => info.State.Running ? docker.stopContainer(containerId) : Effect.void), Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
652
+ yield* updateStatus(SandboxId.make(row.id), "stopped", { legacyRetiredAt: row.legacyRetiredAt });
653
+ return;
654
+ }
278
655
  if (row.accessPassword === null) {
279
656
  const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", row.id);
280
657
  const containerIds = new Set([
281
- ...(row.containerId === null ? [] : [row.containerId]),
658
+ ...(row.containerId === null || row.containerId.length === 0 ? [] : [row.containerId]),
282
659
  ...discovered.map((container) => container.Id)
283
660
  ]);
284
661
  // Do not consider any legacy row reconciled until every persisted
285
662
  // or labeled passwordless container has confirmed shutdown.
286
- yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId), { discard: true });
663
+ yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
287
664
  yield* updateStatus(SandboxId.make(row.id), "error", {
288
665
  error: "Legacy unauthenticated sandbox stopped; delete and recreate it"
289
666
  });
290
667
  return;
291
668
  }
292
- if (!row.containerId) {
669
+ if (row.containerId === null) {
670
+ if (isPreContainerSandboxStatus(row.status)) {
671
+ const activeWorker = yield* hasActiveWorker(row.id);
672
+ if (activeWorker)
673
+ return;
674
+ }
293
675
  yield* updateStatus(SandboxId.make(row.id), "error", { error: "Orphaned (no container)" });
294
676
  return;
295
677
  }
296
- const info = yield* docker.inspectContainer(row.containerId).pipe(Effect.catchIf(() => true, () => Effect.succeed(null)));
297
- if (!info || !info.State.Running) {
678
+ const info = yield* docker.inspectContainer(row.containerId).pipe(Effect.map(Option.some), Effect.catchIf(isMissingContainerError, () => Effect.succeed(Option.none())));
679
+ if (Option.isNone(info) || info.value.State.Running === false) {
298
680
  yield* updateStatus(SandboxId.make(row.id), "stopped");
299
681
  yield* Effect.logInfo(`Reconciled orphaned sandbox ${row.id}`);
300
682
  }
301
683
  }), { discard: true });
302
- }).pipe(Effect.as(true), Effect.catch((cause) => Effect.logWarning("Sandbox reconcile failed", cause).pipe(Effect.as(false)))),
303
- hasLegacyUnauthenticated: () => repo.findAll().pipe(Effect.map((rows) => rows.some((row) => row.accessPassword === null))),
684
+ }).pipe(Effect.as(true), Effect.catch((cause) => Effect.logWarning("Sandbox reconcile failed", cause).pipe(Effect.as(false))), Effect.ensuring(lifecycleAdmission.release(1))),
685
+ hasLegacyUnauthenticated: () => repo.findAll().pipe(Effect.map((rows) => rows.some((row) => row.accessPassword === null || (isRegionlessSandbox(row) && !isCompletedLegacyRetirement(row))))),
304
686
  gcIdle: (idleTimeout = Duration.minutes(30), cleanupDelay = Duration.hours(24)) => Effect.gen(function* () {
305
687
  const all = yield* repo.findAll();
306
688
  const now = yield* Clock.currentTimeMillis;
@@ -310,7 +692,7 @@ const makeSandboxService = Effect.gen(function* () {
310
692
  if (now - lastActivity > Duration.toMillis(idleTimeout)) {
311
693
  return Effect.gen(function* () {
312
694
  yield* Effect.logInfo(`GC: stopping idle sandbox ${row.id}`);
313
- if (row.containerId) {
695
+ if (row.containerId !== null) {
314
696
  yield* docker.stopContainer(row.containerId).pipe(Effect.catchIf(() => true, () => Effect.void));
315
697
  }
316
698
  yield* updateStatus(SandboxId.make(row.id), "stopped");
@@ -325,7 +707,7 @@ const makeSandboxService = Effect.gen(function* () {
325
707
  if (now - lastActivity > Duration.toMillis(cleanupDelay)) {
326
708
  return Effect.gen(function* () {
327
709
  yield* Effect.logInfo(`GC: cleaning up sandbox ${row.id}`);
328
- if (row.containerId) {
710
+ if (row.containerId !== null) {
329
711
  yield* docker.removeContainer(row.containerId).pipe(Effect.catchIf(() => true, () => Effect.void));
330
712
  }
331
713
  yield* fs.remove(row.workspacePath, { recursive: true }).pipe(Effect.catchIf(() => true, () => Effect.void));