@knpkv/codecommit-core 0.15.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 (38) 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 +4 -2
  5. package/dist/CacheService/Database.js.map +1 -1
  6. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.d.ts +6 -0
  7. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.d.ts.map +1 -0
  8. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.js +5 -0
  9. package/dist/CacheService/migrations/0021_sandbox_legacy_retirement.js.map +1 -0
  10. package/dist/CacheService/repos/SandboxRepo.d.ts +8 -0
  11. package/dist/CacheService/repos/SandboxRepo.d.ts.map +1 -1
  12. package/dist/CacheService/repos/SandboxRepo.js +2 -0
  13. package/dist/CacheService/repos/SandboxRepo.js.map +1 -1
  14. package/dist/ConfigService/index.d.ts +1 -1
  15. package/dist/ConfigService/index.d.ts.map +1 -1
  16. package/dist/ConfigService/index.js +1 -1
  17. package/dist/ConfigService/index.js.map +1 -1
  18. package/dist/ConfigService/internal.d.ts +3 -3
  19. package/dist/ConfigService/load.d.ts +3 -3
  20. package/dist/ConfigService/reset.d.ts +3 -3
  21. package/dist/ConfigService/save.d.ts +3 -3
  22. package/dist/ReviewProfile.d.ts +14 -12
  23. package/dist/ReviewProfile.d.ts.map +1 -1
  24. package/dist/ReviewProfile.js +10 -3
  25. package/dist/ReviewProfile.js.map +1 -1
  26. package/dist/SandboxService/DockerService.d.ts +2 -0
  27. package/dist/SandboxService/DockerService.d.ts.map +1 -1
  28. package/dist/SandboxService/DockerService.js +13 -0
  29. package/dist/SandboxService/DockerService.js.map +1 -1
  30. package/dist/SandboxService/SandboxService.d.ts +4 -0
  31. package/dist/SandboxService/SandboxService.d.ts.map +1 -1
  32. package/dist/SandboxService/SandboxService.js +382 -87
  33. package/dist/SandboxService/SandboxService.js.map +1 -1
  34. package/dist/SandboxService/SandboxWorkerScope.d.ts +6 -1
  35. package/dist/SandboxService/SandboxWorkerScope.d.ts.map +1 -1
  36. package/dist/SandboxService/SandboxWorkerScope.js +6 -2
  37. package/dist/SandboxService/SandboxWorkerScope.js.map +1 -1
  38. 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, Ref, 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, isMissingContainerError } 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. */
@@ -28,6 +28,12 @@ export const sandboxRuntimeHome = "/tmp";
28
28
  export const sandboxRuntimeXdgDataHome = `${sandboxRuntimeHome}/.local/share`;
29
29
  const isRegionlessSandbox = (row) => row.region === undefined || row.region === null || row.region === "";
30
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);
31
37
  const homeDir = Config.string("HOME").pipe(Config.orElse(() => Config.string("USERPROFILE")));
32
38
  const sandboxesDir = homeDir.pipe(Config.map((h) => `${h}/.codecommit/sandboxes`));
33
39
  const expandHome = (p, home) => p.startsWith("~/") ? `${home}${p.slice(1)}` : p;
@@ -66,14 +72,101 @@ const makeSandboxService = Effect.gen(function* () {
66
72
  const cryptoService = yield* Crypto.Crypto;
67
73
  const homePath = yield* homeDir.pipe(Effect.orDie);
68
74
  const basePath = yield* sandboxesDir.pipe(Effect.orDie);
69
- const activeWorkerIds = yield* Ref.make(new Set());
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([]);
70
129
  const loadSandboxConfig = configService.load.pipe(Effect.map((config) => config.sandbox), Effect.catch(() => Effect.succeed(defaultSandboxConfig)));
71
130
  const updateStatus = (id, status, extra) => repo.updateStatus(id, status, extra);
72
- 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;
73
166
  yield* Effect.logError(`Sandbox ${id} creation failed`, error);
74
167
  const errorDetail = Result.try(() => String(Predicate.isError(error) ? error.message : error)).pipe(Result.getOrElse(() => "Unknown error"));
75
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)));
76
- });
169
+ }));
77
170
  const progress = (id, detail) => Clock.currentTimeMillis.pipe(Effect.flatMap((ms) => {
78
171
  const ts = new Date(ms).toISOString();
79
172
  return repo.updateDetail(id, detail).pipe(Effect.tap(() => repo.appendLog(id, `[${ts}] ${detail}`)), Effect.tap(() => Effect.logInfo(`Sandbox ${id}: ${detail}`)));
@@ -90,15 +183,90 @@ const makeSandboxService = Effect.gen(function* () {
90
183
  sourceBranch: row.sourceBranch
91
184
  }
92
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() })));
93
213
  const service = {
94
214
  create: (params) => Effect.gen(function* () {
215
+ yield* Effect.uninterruptible(createAdmission.take(1));
95
216
  // Singleton check — one active sandbox per PR
96
217
  const existing = yield* repo.findByPr(params.awsAccountId, params.pullRequestId, params.repositoryName, params.region);
97
- const exactExisting = Option.isSome(existing) && existing.value.region === params.region
218
+ const exactCandidate = Option.isSome(existing) && existing.value.region === params.region
98
219
  ? existing.value
99
220
  : undefined;
100
- const regionless = yield* repo.findRegionlessByPrAll(params.awsAccountId, params.pullRequestId, params.repositoryName);
101
- const legacyResults = yield* Effect.forEach(regionless, (legacy) => Effect.gen(function* () {
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
+ }
102
270
  const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", legacy.id);
103
271
  const containerIds = new Set([
104
272
  ...(legacy.containerId === null || legacy.containerId.length === 0 ? [] : [legacy.containerId]),
@@ -110,17 +278,20 @@ const makeSandboxService = Effect.gen(function* () {
110
278
  message: "Regionless legacy sandbox is still starting; retry after its worker reports a container"
111
279
  });
112
280
  }
281
+ if (legacy.accessPassword !== null) {
282
+ yield* markLegacyRetirementStarted(SandboxId.make(legacy.id));
283
+ }
113
284
  if (legacy.accessPassword === null) {
114
- yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf(isMissingContainerError, () => Effect.void)), { discard: true });
285
+ yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
115
286
  yield* updateStatus(SandboxId.make(legacy.id), "error", {
116
287
  error: "Legacy unauthenticated sandbox stopped; delete and recreate it"
117
288
  });
118
289
  return Option.some(legacy);
119
290
  }
120
- yield* Effect.forEach(containerIds, (containerId) => docker.inspectContainer(containerId).pipe(Effect.flatMap((info) => info.State.Running ? docker.stopContainer(containerId) : Effect.void), Effect.catchIf(isMissingContainerError, () => Effect.void)), { discard: true });
121
- yield* updateStatus(SandboxId.make(legacy.id), "stopped");
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));
122
293
  return Option.none();
123
- }), { concurrency: 1 });
294
+ })), { concurrency: 1 });
124
295
  const unauthenticated = legacyResults.find(Option.isSome);
125
296
  if (unauthenticated !== undefined && Option.isSome(unauthenticated)) {
126
297
  return yield* new SandboxError({
@@ -128,17 +299,24 @@ const makeSandboxService = Effect.gen(function* () {
128
299
  message: "Regionless legacy sandbox requires explicit cleanup before recreation"
129
300
  });
130
301
  }
131
- if (exactExisting !== undefined) {
132
- if (exactExisting.containerId === null && isPreContainerSandboxStatus(exactExisting.status)) {
133
- const activeWorker = yield* Ref.get(activeWorkerIds).pipe(Effect.map((ids) => ids.has(exactExisting.id)));
134
- if (activeWorker)
135
- return exactExisting;
136
- yield* updateStatus(SandboxId.make(exactExisting.id), "error", {
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", {
137
315
  error: "Orphaned (no container)"
138
316
  });
139
317
  }
140
318
  else {
141
- return exactExisting;
319
+ return effectiveExisting;
142
320
  }
143
321
  }
144
322
  const sandboxCfg = yield* loadSandboxConfig;
@@ -150,28 +328,17 @@ const makeSandboxService = Effect.gen(function* () {
150
328
  const accessPassword = yield* cryptoService.randomUUIDv4;
151
329
  const workspacePath = `${basePath}/${id}`;
152
330
  const now = new Date(nowMs).toISOString();
153
- yield* repo.insert({
154
- id,
155
- pullRequestId: params.pullRequestId,
156
- awsAccountId: params.awsAccountId,
157
- repositoryName: params.repositoryName,
158
- region: params.region,
159
- sourceBranch: params.sourceBranch,
160
- accessPassword,
161
- workspacePath,
162
- status: "creating",
163
- createdAt: now,
164
- lastActivityAt: now
165
- });
166
- // Fork daemon for async lifecycle
167
- yield* Ref.update(activeWorkerIds, (ids) => new Set(ids).add(String(id)));
168
- 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* () {
169
334
  const fs = yield* FileSystem.FileSystem;
170
335
  const host = yield* ChildEnv.HostEnvironment;
171
336
  const log = (detail) => progress(id, detail);
337
+ if (yield* isStopRequested(String(id)))
338
+ return;
172
339
  yield* log("Sandbox config validated");
173
340
  // Clone via HTTPS + AWS credential helper
174
- yield* updateStatus(id, "cloning");
341
+ yield* updateWorkerStatus(id, "cloning");
175
342
  yield* fs.makeDirectory(workspacePath, { recursive: true });
176
343
  const cloneUrl = `https://git-codecommit.${params.region}.amazonaws.com/v1/repos/${params.repositoryName}`;
177
344
  const branch = params.sourceBranch.replace(/^refs\/heads\//, "");
@@ -212,12 +379,14 @@ const makeSandboxService = Effect.gen(function* () {
212
379
  if (cloneResult.exitCode !== 0) {
213
380
  const stderrText = cloneResult.stderrText;
214
381
  yield* log(`Clone failed: ${stderrText}`);
215
- yield* updateStatus(id, "error", {
382
+ yield* updateWorkerStatus(id, "error", {
216
383
  error: stderrText || `git clone failed (exit ${cloneResult.exitCode})`
217
384
  });
218
385
  return;
219
386
  }
220
387
  yield* log("Clone complete");
388
+ if (yield* isStopRequested(String(id)))
389
+ return;
221
390
  const workspaceInfo = yield* fs.stat(workspacePath);
222
391
  const containerIdentity = sandboxContainerIdentityForWorkspaceOwner(Option.getOrUndefined(workspaceInfo.uid), Option.getOrUndefined(workspaceInfo.gid));
223
392
  if (containerIdentity.repairRootOwnedWorkspace) {
@@ -231,22 +400,40 @@ const makeSandboxService = Effect.gen(function* () {
231
400
  }
232
401
  }
233
402
  // Pull image
234
- yield* updateStatus(id, "starting");
403
+ yield* updateWorkerStatus(id, "starting");
235
404
  yield* log(`Pulling image ${sandboxCfg.image}`);
236
405
  yield* docker.pullImage(sandboxCfg.image).pipe(Effect.tap(() => log("Image ready")), Effect.catchIf(() => true, () => log("Image pull skipped (using cached)")));
237
- // Create + start container
238
- yield* log("Creating container");
239
- yield* validateSandboxConfig(sandboxCfg, homePath);
240
- const containerConfig = makeContainerConfig(workspacePath, port, id, params.pullRequestId, sandboxCfg, homePath, containerIdentity.user, accessPassword);
241
- const containerId = yield* docker.createContainer(containerConfig);
242
- const cid = containerId.trim();
243
- yield* log(`Container ${cid.slice(0, 12)} created, starting`);
244
- yield* docker.startContainer(cid);
245
- yield* updateStatus(id, "starting", { containerId: cid, port });
246
- 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;
247
432
  // Wait for code-server to be ready (poll health)
248
433
  yield* log("Waiting for code-server health check");
249
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;
250
437
  // Run plugin hooks
251
438
  const row = yield* repo.findById(id);
252
439
  const ctx = makeSandboxContext(row);
@@ -263,36 +450,98 @@ const makeSandboxService = Effect.gen(function* () {
263
450
  yield* log(`Running ${sandboxCfg.setupCommands.length} setup command(s)`);
264
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 });
265
452
  }
266
- yield* updateStatus(id, "running");
453
+ yield* updateWorkerStatus(id, "running");
267
454
  yield* log("Sandbox ready");
268
455
  }).pipe(Effect.catch((error) => recordCreationFailure(id, error)),
269
456
  // Observe and persist unexpected defects without recovering them.
270
457
  // `tapDefect` leaves the original Cause / Exit unchanged.
271
- Effect.tapDefect((defect) => recordCreationFailure(id, defect)), Effect.ensuring(Ref.update(activeWorkerIds, (ids) => {
272
- const next = new Set(ids);
273
- next.delete(String(id));
274
- return next;
275
- }))));
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 }));
276
488
  return yield* repo.findById(id);
277
- }).pipe(Effect.mapError((cause) => Predicate.isTagged(cause, "SandboxError")
489
+ }).pipe(Effect.ensuring(createAdmission.release(1)), Effect.mapError((cause) => Predicate.isTagged(cause, "SandboxError")
278
490
  ? cause
279
491
  : new SandboxError({ message: "Failed to create sandbox", cause }))),
280
492
  get: (id) => repo.findById(id).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Sandbox not found", cause }))),
281
493
  list: () => repo.findActive(),
282
494
  listAll: () => repo.findAll(),
283
- stop: (id) => Effect.gen(function* () {
284
- const row = yield* repo.findById(id);
285
- yield* updateStatus(id, "stopping");
286
- if (row.containerId !== null) {
287
- const ctx = makeSandboxContext(row);
288
- yield* plugins.executeHook("onSandboxDestroy", ctx);
289
- yield* docker.stopContainer(row.containerId).pipe(Effect.catchIf(() => true, () => Effect.void));
290
- }
291
- yield* updateStatus(id, "stopped");
292
- 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)))));
293
527
  }).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Failed to stop sandbox", cause }))),
294
- 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* () {
295
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
+ }
296
545
  if (row.accessPassword === null) {
297
546
  return yield* new SandboxError({
298
547
  sandboxId: id,
@@ -302,18 +551,47 @@ const makeSandboxService = Effect.gen(function* () {
302
551
  if (row.containerId === null) {
303
552
  return yield* new SandboxError({ sandboxId: id, message: "No container to restart" });
304
553
  }
305
- yield* updateStatus(id, "starting");
306
- yield* progress(id, "Restarting container");
307
- yield* docker.startContainer(row.containerId);
308
- yield* updateStatus(id, "starting", row.port !== null ? { port: row.port } : {});
309
- yield* progress(id, "Waiting for code-server health check");
310
- 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));
311
- const ctx = makeSandboxContext(yield* repo.findById(id));
312
- yield* plugins.executeHook("onSandboxReady", ctx);
313
- yield* updateStatus(id, "running");
314
- yield* progress(id, "Sandbox restarted");
315
- yield* Effect.logInfo(`Sandbox ${id} restarted`);
316
- }).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 }))),
317
595
  cleanup: (id) => Effect.gen(function* () {
318
596
  const row = yield* repo.findById(id);
319
597
  const fs = yield* FileSystem.FileSystem;
@@ -325,10 +603,13 @@ const makeSandboxService = Effect.gen(function* () {
325
603
  yield* Effect.logInfo(`Sandbox ${id} cleaned up`);
326
604
  }).pipe(Effect.mapError((cause) => new SandboxError({ sandboxId: id, message: "Failed to cleanup sandbox", cause }))),
327
605
  reconcile: () => Effect.gen(function* () {
606
+ yield* Effect.uninterruptible(lifecycleAdmission.take(1));
328
607
  const active = yield* repo.findActive();
329
608
  const all = yield* repo.findAll();
330
609
  const activeIds = new Set(active.map((row) => row.id));
331
- const rows = all.filter((row) => row.accessPassword === null || activeIds.has(row.id) || isRegionlessSandbox(row));
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")));
332
613
  yield* Effect.forEach(rows, (row) => Effect.gen(function* () {
333
614
  if (isRegionlessSandbox(row)) {
334
615
  const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", row.id);
@@ -339,24 +620,38 @@ const makeSandboxService = Effect.gen(function* () {
339
620
  if (row.accessPassword !== null &&
340
621
  containerIds.size === 0 &&
341
622
  isPreContainerSandboxStatus(row.status)) {
342
- const activeWorker = yield* Ref.get(activeWorkerIds).pipe(Effect.map((ids) => ids.has(row.id)));
623
+ const activeWorker = yield* hasActiveWorker(row.id);
343
624
  if (activeWorker)
344
625
  return;
345
626
  yield* updateStatus(SandboxId.make(row.id), "error", { error: "Orphaned (no container)" });
346
627
  return;
347
628
  }
348
629
  if (row.accessPassword === null) {
349
- yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf(isMissingContainerError, () => Effect.void)), { discard: true });
630
+ yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
350
631
  yield* updateStatus(SandboxId.make(row.id), "error", {
351
632
  error: "Legacy unauthenticated sandbox stopped; delete and recreate it"
352
633
  });
353
634
  return;
354
635
  }
355
- yield* Effect.forEach(containerIds, (containerId) => docker.inspectContainer(containerId).pipe(Effect.flatMap((info) => info.State.Running ? docker.stopContainer(containerId) : Effect.void), Effect.catchIf(isMissingContainerError, () => Effect.void)), { discard: true });
356
- yield* updateStatus(SandboxId.make(row.id), "stopped");
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));
357
642
  yield* Effect.logInfo(`Reconciled regionless sandbox ${row.id}`);
358
643
  return;
359
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
+ }
360
655
  if (row.accessPassword === null) {
361
656
  const discovered = yield* docker.listContainersByLabel("codecommit.sandbox.id", row.id);
362
657
  const containerIds = new Set([
@@ -365,7 +660,7 @@ const makeSandboxService = Effect.gen(function* () {
365
660
  ]);
366
661
  // Do not consider any legacy row reconciled until every persisted
367
662
  // or labeled passwordless container has confirmed shutdown.
368
- yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf(isMissingContainerError, () => Effect.void)), { discard: true });
663
+ yield* Effect.forEach(containerIds, (containerId) => docker.stopContainer(containerId).pipe(Effect.catchIf((error) => isMissingContainerError(error) || isAlreadyStoppedContainerError(error), () => Effect.void)), { discard: true });
369
664
  yield* updateStatus(SandboxId.make(row.id), "error", {
370
665
  error: "Legacy unauthenticated sandbox stopped; delete and recreate it"
371
666
  });
@@ -373,7 +668,7 @@ const makeSandboxService = Effect.gen(function* () {
373
668
  }
374
669
  if (row.containerId === null) {
375
670
  if (isPreContainerSandboxStatus(row.status)) {
376
- const activeWorker = yield* Ref.get(activeWorkerIds).pipe(Effect.map((ids) => ids.has(row.id)));
671
+ const activeWorker = yield* hasActiveWorker(row.id);
377
672
  if (activeWorker)
378
673
  return;
379
674
  }
@@ -386,8 +681,8 @@ const makeSandboxService = Effect.gen(function* () {
386
681
  yield* Effect.logInfo(`Reconciled orphaned sandbox ${row.id}`);
387
682
  }
388
683
  }), { discard: true });
389
- }).pipe(Effect.as(true), Effect.catch((cause) => Effect.logWarning("Sandbox reconcile failed", cause).pipe(Effect.as(false)))),
390
- hasLegacyUnauthenticated: () => repo.findAll().pipe(Effect.map((rows) => rows.some((row) => row.accessPassword === null || isRegionlessSandbox(row)))),
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))))),
391
686
  gcIdle: (idleTimeout = Duration.minutes(30), cleanupDelay = Duration.hours(24)) => Effect.gen(function* () {
392
687
  const all = yield* repo.findAll();
393
688
  const now = yield* Clock.currentTimeMillis;