@atolis-hq/wake 0.4.49 → 0.4.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/bootstrap/composition-root.js +6 -0
- package/dist/src/bootstrap/npm-update-port.js +3 -2
- package/dist/src/bootstrap/self-update-application.d.ts +2 -0
- package/dist/src/bootstrap/self-update-application.js +23 -5
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/execution/application/execution-validation.js +17 -3
- package/dist/src/execution/contracts/workspace.d.ts +11 -1
- package/dist/src/execution/infrastructure/workspace/git-workspace.d.ts +10 -5
- package/dist/src/execution/infrastructure/workspace/git-workspace.js +270 -38
- package/dist/src/surfaces/web-assets/assets/index-Be7GB8fP.css +1 -0
- package/dist/src/surfaces/web-assets/assets/{index-CuIiUKuh.js → index-C23a9E8y.js} +1 -1
- package/dist/src/surfaces/web-assets/index.html +2 -2
- package/package.json +3 -1
- package/dist/src/surfaces/web-assets/assets/index-DKpuc-RV.css +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { acquireFileLock } from '@atolis-hq/eventing-filesystem';
|
|
1
2
|
import { createPullRequestService } from '../activities/index.js';
|
|
2
3
|
import { FileArtifactMcpSessionStore, FileArtifactStore, createArtifactService, } from '../artifacts/index.js';
|
|
3
4
|
import { DispatchPolicy, createActivationScheduler, createActivationSchedulerSubscriber, createControlPlaneService, createDurableRunnerIneligibility, createRunnerControlService, } from '../control-plane/index.js';
|
|
@@ -63,6 +64,11 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
|
|
|
63
64
|
throw new Error('Workspace resource ' + id + ' does not identify a GitHub repository');
|
|
64
65
|
return 'https://github.com/' + match[1] + '.git';
|
|
65
66
|
},
|
|
67
|
+
}, {
|
|
68
|
+
tryAcquire: (path) => acquireFileLock(path, {
|
|
69
|
+
staleAfterMs: 0,
|
|
70
|
+
staleRequiresDeadProcess: true,
|
|
71
|
+
}),
|
|
66
72
|
}, undefined, undefined, config.execution.workspaceHooks?.prepare);
|
|
67
73
|
const transcriptStore = config.transcripts.enabled
|
|
68
74
|
? (options.transcriptStore ?? new TranscriptStore(paths.transcriptsRoot))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile as nodeExecFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { compare } from 'semver';
|
|
4
4
|
const execFile = promisify(nodeExecFile);
|
|
5
5
|
/** Resolves immutable published package versions. Installation is performed by the rollout. */
|
|
6
6
|
export function createNpmUpdatePort(input) {
|
|
@@ -9,7 +9,8 @@ export function createNpmUpdatePort(input) {
|
|
|
9
9
|
const registry = input.registry === undefined ? [] : [`--registry=${input.registry}`];
|
|
10
10
|
return {
|
|
11
11
|
isClean: async () => true,
|
|
12
|
-
|
|
12
|
+
// Package updates compare against the deployment ledger, not the host CLI.
|
|
13
|
+
compareTags: compare,
|
|
13
14
|
async latestTag() {
|
|
14
15
|
const resolved = await resolveVersion();
|
|
15
16
|
if (resolved === undefined)
|
|
@@ -4,6 +4,8 @@ export interface SourceUpdatePort {
|
|
|
4
4
|
isClean(): Promise<boolean>;
|
|
5
5
|
/** The version currently running before any update starts, when available. */
|
|
6
6
|
currentTag?(): Promise<string | null>;
|
|
7
|
+
/** Semantic ordering when discovery omits older versions; current state is the ledger. */
|
|
8
|
+
compareTags?(candidate: string, current: string): number;
|
|
7
9
|
latestTag(): Promise<string>;
|
|
8
10
|
candidateTags(): Promise<readonly string[]>;
|
|
9
11
|
checkout(tag: string): Promise<void>;
|
|
@@ -3,6 +3,8 @@ import { UpdateMaintenancePhase } from './update-maintenance-lease.js';
|
|
|
3
3
|
export function createSelfUpdateApplication(input) {
|
|
4
4
|
return {
|
|
5
5
|
async update(tag, force = false) {
|
|
6
|
+
if (!(await isNewerOrderedVersion(input, tag)))
|
|
7
|
+
return false;
|
|
6
8
|
if (!force && (await input.ledger.isBad(tag)))
|
|
7
9
|
return false;
|
|
8
10
|
if (!force && !(await isLaterCandidate(input, tag)))
|
|
@@ -15,10 +17,7 @@ export function createSelfUpdateApplication(input) {
|
|
|
15
17
|
if (latestCandidate === undefined)
|
|
16
18
|
throw new Error('No update versions are available');
|
|
17
19
|
const current = await currentTag(input);
|
|
18
|
-
const
|
|
19
|
-
// Candidates are newest-first. The current healthy tag is a hard
|
|
20
|
-
// boundary: never enter maintenance to reapply or downgrade from it.
|
|
21
|
-
const laterCandidates = currentIndex === -1 ? candidates : candidates.slice(0, currentIndex);
|
|
20
|
+
const laterCandidates = selectLaterCandidates(input.source, candidates, current);
|
|
22
21
|
for (const tag of laterCandidates) {
|
|
23
22
|
if (!force && (await input.ledger.isBad(tag))) {
|
|
24
23
|
continue;
|
|
@@ -42,7 +41,17 @@ export function createSelfUpdateApplication(input) {
|
|
|
42
41
|
},
|
|
43
42
|
};
|
|
44
43
|
}
|
|
44
|
+
function selectLaterCandidates(source, candidates, current) {
|
|
45
|
+
const compareTags = source.compareTags;
|
|
46
|
+
if (current !== null && compareTags !== undefined)
|
|
47
|
+
return candidates.filter((tag) => compareTags(tag, current) > 0);
|
|
48
|
+
const currentIndex = current === null ? -1 : candidates.indexOf(current);
|
|
49
|
+
// Newest-first source tags stop at the current healthy revision.
|
|
50
|
+
return currentIndex === -1 ? candidates : candidates.slice(0, currentIndex);
|
|
51
|
+
}
|
|
45
52
|
async function isLaterCandidate(input, tag) {
|
|
53
|
+
if (input.source.compareTags !== undefined)
|
|
54
|
+
return isNewerOrderedVersion(input, tag);
|
|
46
55
|
const candidates = await input.source.candidateTags();
|
|
47
56
|
const candidateIndex = candidates.indexOf(tag);
|
|
48
57
|
if (candidateIndex === -1)
|
|
@@ -55,7 +64,15 @@ async function isLaterCandidate(input, tag) {
|
|
|
55
64
|
return candidateIndex === 0;
|
|
56
65
|
return candidateIndex < currentIndex;
|
|
57
66
|
}
|
|
67
|
+
async function isNewerOrderedVersion(input, tag) {
|
|
68
|
+
if (input.source.compareTags === undefined)
|
|
69
|
+
return true;
|
|
70
|
+
const current = await currentTag(input);
|
|
71
|
+
return current === null || input.source.compareTags(tag, current) > 0;
|
|
72
|
+
}
|
|
58
73
|
async function currentTag(input) {
|
|
74
|
+
if (input.source.compareTags !== undefined)
|
|
75
|
+
return input.ledger.read();
|
|
59
76
|
return (await input.source.currentTag?.()) ?? (await input.ledger.read());
|
|
60
77
|
}
|
|
61
78
|
async function startUpdateAttempt(input, tag, force) {
|
|
@@ -95,7 +112,8 @@ async function performUpdateAttempt(input, tag, force, lease, context) {
|
|
|
95
112
|
return false;
|
|
96
113
|
await quiesceForUpdate(input);
|
|
97
114
|
await recoverPendingUpdate(input.ledger, input.source, context);
|
|
98
|
-
if (!
|
|
115
|
+
if (!(await isNewerOrderedVersion(input, tag)) ||
|
|
116
|
+
(!force && (await input.ledger.read()) === tag)) {
|
|
99
117
|
await input.quiesce?.clear(lease?.attemptId);
|
|
100
118
|
return false;
|
|
101
119
|
}
|
|
@@ -124,6 +124,6 @@ export function resolveWakeVersion(options = {}) {
|
|
|
124
124
|
return `g${headHash.slice(0, 7)}`;
|
|
125
125
|
return '0.1.0-dev';
|
|
126
126
|
}
|
|
127
|
-
export const wakeVersion = "v0.4.
|
|
127
|
+
export const wakeVersion = "v0.4.51";
|
|
128
128
|
/** The registry-installable version used by packaged sandbox images. */
|
|
129
129
|
export const wakePackageVersion = packageVersion(defaultRepoRoot) ?? wakeVersion;
|
|
@@ -7,12 +7,26 @@ export async function acquireWorkspace(runId, mode, workItemId, resources, provi
|
|
|
7
7
|
if (provider === undefined)
|
|
8
8
|
throw new Error('Workspace provider is required');
|
|
9
9
|
const repositoryResource = resources.find((resource) => resource.kind === BuiltInResourceKind.Repository) ??
|
|
10
|
-
resources.find((resource) => resource.kind === BuiltInResourceKind.
|
|
11
|
-
|
|
10
|
+
resources.find((resource) => resource.kind === BuiltInResourceKind.PullRequest) ??
|
|
11
|
+
resources.find((resource) => resource.kind === BuiltInResourceKind.Issue);
|
|
12
12
|
if (repositoryResource === undefined)
|
|
13
13
|
throw new Error('Repository Resource is required');
|
|
14
14
|
signal.throwIfAborted();
|
|
15
|
-
|
|
15
|
+
const revision = repositoryResource.revision;
|
|
16
|
+
const checkoutRevision = repositoryResource.kind === BuiltInResourceKind.Repository ||
|
|
17
|
+
(repositoryResource.kind === BuiltInResourceKind.PullRequest &&
|
|
18
|
+
revision !== undefined &&
|
|
19
|
+
/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(revision))
|
|
20
|
+
? revision
|
|
21
|
+
: undefined;
|
|
22
|
+
return provider.acquire({
|
|
23
|
+
runId,
|
|
24
|
+
signal,
|
|
25
|
+
mode,
|
|
26
|
+
workItemId,
|
|
27
|
+
repositoryResource,
|
|
28
|
+
...(checkoutRevision === undefined ? {} : { checkoutRevision }),
|
|
29
|
+
});
|
|
16
30
|
}
|
|
17
31
|
export function validateResourceRequirements(requirements, resources) {
|
|
18
32
|
for (const requirement of requirements) {
|
|
@@ -9,6 +9,8 @@ export interface WorkspaceRequest {
|
|
|
9
9
|
readonly mode: typeof WorkspaceMode.ReadOnly | typeof WorkspaceMode.Branch;
|
|
10
10
|
readonly workItemId: WorkItemId;
|
|
11
11
|
readonly repositoryResource: ResourceView;
|
|
12
|
+
/** Git checkout target, independent of the resource observation revision. */
|
|
13
|
+
readonly checkoutRevision?: string;
|
|
12
14
|
}
|
|
13
15
|
export interface WorkspaceLease {
|
|
14
16
|
readonly workspaceId: string;
|
|
@@ -16,7 +18,7 @@ export interface WorkspaceLease {
|
|
|
16
18
|
/** Branch actually checked out for this lease, when the provider can attest it. */
|
|
17
19
|
readonly branch?: string | undefined;
|
|
18
20
|
readonly mode: WorkspaceRequest['mode'];
|
|
19
|
-
/** Ends this Run's use of the workspace;
|
|
21
|
+
/** Ends this Run's use of the workspace; workspace disposal is WorkItem-scoped recovery. */
|
|
20
22
|
release(): Promise<void>;
|
|
21
23
|
}
|
|
22
24
|
export interface WorkspaceProvider {
|
|
@@ -46,3 +48,11 @@ export interface WorkspaceRecoveryFailure {
|
|
|
46
48
|
readonly path: string;
|
|
47
49
|
readonly message: string;
|
|
48
50
|
}
|
|
51
|
+
/** Process-owned exclusion shared by acquisition and recovery. */
|
|
52
|
+
export interface WorkspaceLockProvider {
|
|
53
|
+
tryAcquire(path: string): Promise<WorkspaceLock>;
|
|
54
|
+
}
|
|
55
|
+
export interface WorkspaceLock {
|
|
56
|
+
readonly acquired: boolean;
|
|
57
|
+
release(): Promise<void>;
|
|
58
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { WorkItemId } from '../../../work/index.js';
|
|
2
1
|
import type { RunView } from '../../contracts/views.js';
|
|
3
|
-
import type { WorkspaceProvider, WorkspaceRecovery, WorkspaceRecoveryOptions, WorkspaceRecoveryResult, WorkspaceRequest } from '../../contracts/workspace.js';
|
|
2
|
+
import type { WorkspaceLockProvider, WorkspaceProvider, WorkspaceRecovery, WorkspaceRecoveryOptions, WorkspaceRecoveryResult, WorkspaceRequest } from '../../contracts/workspace.js';
|
|
4
3
|
import { type WorkspacePrepareHook } from './prepare-workspace.js';
|
|
5
4
|
export interface RepositoryCloneResolver {
|
|
6
5
|
cloneLocator(resourceId: string, signal: AbortSignal): Promise<string>;
|
|
7
6
|
}
|
|
8
|
-
export type GitRunner = (arguments_: readonly string[], signal: AbortSignal) => Promise<void>;
|
|
7
|
+
export type GitRunner = (arguments_: readonly string[], signal: AbortSignal) => Promise<string | void>;
|
|
9
8
|
export interface WorkspaceRecoveryFileSystem {
|
|
10
9
|
remove(path: string): Promise<void>;
|
|
11
10
|
canonicalize(path: string): Promise<string>;
|
|
@@ -13,17 +12,23 @@ export interface WorkspaceRecoveryFileSystem {
|
|
|
13
12
|
export declare class GitWorkspaceProvider implements WorkspaceProvider, WorkspaceRecovery {
|
|
14
13
|
private readonly root;
|
|
15
14
|
private readonly resolver;
|
|
15
|
+
private readonly locks;
|
|
16
16
|
private readonly git;
|
|
17
17
|
private readonly prepareHook?;
|
|
18
18
|
private readonly markerRoot;
|
|
19
19
|
private readonly recoveryFileSystem;
|
|
20
|
-
|
|
20
|
+
private readonly pendingReleases;
|
|
21
|
+
constructor(root: string, resolver: RepositoryCloneResolver, locks: WorkspaceLockProvider, git?: GitRunner, recoveryFileSystem?: WorkspaceRecoveryFileSystem, prepareHook?: WorkspacePrepareHook);
|
|
21
22
|
acquire(request: WorkspaceRequest): Promise<{
|
|
22
23
|
release: () => Promise<void>;
|
|
23
|
-
branch?:
|
|
24
|
+
branch?: string;
|
|
24
25
|
workspaceId: string;
|
|
25
26
|
path: string;
|
|
26
27
|
mode: "read-only" | "branch";
|
|
27
28
|
}>;
|
|
29
|
+
private releaseWorkspaceLock;
|
|
30
|
+
private prepareCheckout;
|
|
31
|
+
private reuseCheckout;
|
|
32
|
+
private validateLegacyCheckout;
|
|
28
33
|
recover(runs: readonly RunView[], options?: WorkspaceRecoveryOptions): Promise<WorkspaceRecoveryResult>;
|
|
29
34
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
|
-
import { access, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { access, mkdir, readdir, readFile, realpath, rename, rm, rmdir, unlink, writeFile, } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { isActiveRunStatus, RunStatus, WorkspaceMode } from '../../contracts/vocabulary.js';
|
|
@@ -9,18 +9,21 @@ const exec = promisify(execFile);
|
|
|
9
9
|
export class GitWorkspaceProvider {
|
|
10
10
|
root;
|
|
11
11
|
resolver;
|
|
12
|
+
locks;
|
|
12
13
|
git;
|
|
13
14
|
prepareHook;
|
|
14
15
|
markerRoot;
|
|
15
16
|
recoveryFileSystem;
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
pendingReleases = new Map();
|
|
18
|
+
constructor(root, resolver, locks, git = async (arguments_, signal) => {
|
|
19
|
+
return (await exec('git', arguments_, { signal })).stdout.trim();
|
|
18
20
|
}, recoveryFileSystem = {
|
|
19
21
|
remove: async (path) => rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
|
|
20
22
|
canonicalize: realpath,
|
|
21
23
|
}, prepareHook) {
|
|
22
24
|
this.root = root;
|
|
23
25
|
this.resolver = resolver;
|
|
26
|
+
this.locks = locks;
|
|
24
27
|
this.git = git;
|
|
25
28
|
this.prepareHook = prepareHook;
|
|
26
29
|
this.markerRoot = join(this.root, '.wake-workspace-ownership');
|
|
@@ -34,59 +37,161 @@ export class GitWorkspaceProvider {
|
|
|
34
37
|
const name = `${request.workItemId}-${request.mode}-${slug(locator)}`;
|
|
35
38
|
const path = resolve(this.root, name);
|
|
36
39
|
const markerPath = join(this.markerRoot, `${name}.json`);
|
|
40
|
+
const lockPath = join(this.markerRoot, `${name}.lease`);
|
|
37
41
|
await mkdir(dirname(markerPath), { recursive: true });
|
|
42
|
+
const scope = await recoveryScope(this.root, this.markerRoot);
|
|
43
|
+
if (scope === null)
|
|
44
|
+
throw new Error('Workspace ownership directory is outside the managed root');
|
|
45
|
+
await validateLeasePaths(scope, lockPath);
|
|
46
|
+
const lock = await acquireWorkspaceLock(this.locks, lockPath, signal);
|
|
47
|
+
try {
|
|
48
|
+
const branch = await this.prepareCheckout(request, locator, name, path, markerPath);
|
|
49
|
+
if (this.prepareHook !== undefined)
|
|
50
|
+
await prepareWorkspace(path, this.prepareHook, signal);
|
|
51
|
+
signal.throwIfAborted();
|
|
52
|
+
let released = false;
|
|
53
|
+
return {
|
|
54
|
+
workspaceId: name,
|
|
55
|
+
path,
|
|
56
|
+
mode: request.mode,
|
|
57
|
+
...(branch === undefined ? {} : { branch }),
|
|
58
|
+
release: async () => {
|
|
59
|
+
if (released)
|
|
60
|
+
return;
|
|
61
|
+
await this.releaseWorkspaceLock(lockPath, lock);
|
|
62
|
+
released = true;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await this.releaseWorkspaceLock(lockPath, lock);
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async releaseWorkspaceLock(path, lock) {
|
|
72
|
+
try {
|
|
73
|
+
await lock.release();
|
|
74
|
+
if (this.pendingReleases.get(path) === lock)
|
|
75
|
+
this.pendingReleases.delete(path);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
this.pendingReleases.set(path, lock);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async prepareCheckout(request, locator, name, path, markerPath) {
|
|
83
|
+
const { signal } = request;
|
|
84
|
+
const previousMarker = await readMarker(markerPath);
|
|
85
|
+
if (await exists(join(this.markerRoot, `${name}.lock`), signal))
|
|
86
|
+
throw new Error('Legacy workspace lock requires recovery before acquisition');
|
|
87
|
+
let initialRevision = previousMarker?.initialRevision;
|
|
88
|
+
let checkoutReady;
|
|
38
89
|
signal.throwIfAborted();
|
|
39
|
-
|
|
90
|
+
const writeMarker = () => writeOwnershipMarker(markerPath, {
|
|
40
91
|
runId: request.runId,
|
|
41
92
|
workItemId: request.workItemId,
|
|
42
93
|
repositoryResourceId: request.repositoryResource.resourceId,
|
|
43
94
|
mode: request.mode,
|
|
44
95
|
workspaceId: name,
|
|
45
96
|
path,
|
|
46
|
-
|
|
47
|
-
|
|
97
|
+
checkoutReady,
|
|
98
|
+
...(initialRevision === undefined ? {} : { initialRevision }),
|
|
99
|
+
}, signal);
|
|
100
|
+
const existingWorkspace = await this.reuseCheckout(previousMarker, markerPath, path, request.mode, signal);
|
|
101
|
+
checkoutReady = previousMarker?.checkoutReady ?? existingWorkspace;
|
|
102
|
+
if (!existingWorkspace) {
|
|
103
|
+
initialRevision = undefined;
|
|
104
|
+
checkoutReady = false;
|
|
105
|
+
}
|
|
106
|
+
await writeMarker();
|
|
48
107
|
if (!existingWorkspace) {
|
|
49
108
|
await mkdir(dirname(path), { recursive: true });
|
|
50
109
|
signal.throwIfAborted();
|
|
51
110
|
await this.git(['clone', locator, path], signal);
|
|
52
111
|
}
|
|
112
|
+
if (initialRevision === undefined)
|
|
113
|
+
initialRevision = await readCheckoutRevision(path, this.git, signal);
|
|
53
114
|
const branch = request.mode === WorkspaceMode.Branch ? request.workItemId : undefined;
|
|
54
|
-
if (branch !== undefined)
|
|
115
|
+
if (branch !== undefined) {
|
|
55
116
|
await this.git(['-C', path, 'switch', ...(existingWorkspace ? [] : ['--create']), branch], signal);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
117
|
+
}
|
|
118
|
+
checkoutReady = true;
|
|
119
|
+
await writeMarker();
|
|
120
|
+
if (branch === undefined) {
|
|
121
|
+
await restoreReadOnlyWorkspace(path, request.checkoutRevision, initialRevision, this.git, signal);
|
|
122
|
+
}
|
|
123
|
+
return branch;
|
|
124
|
+
}
|
|
125
|
+
async reuseCheckout(marker, markerPath, path, mode, signal) {
|
|
126
|
+
if (!(await exists(path, signal)))
|
|
127
|
+
return false;
|
|
128
|
+
const scope = await recoveryScope(this.root, this.markerRoot);
|
|
129
|
+
if (scope === null ||
|
|
130
|
+
marker === null ||
|
|
131
|
+
!(await isOwnedWorkspace(scope, markerPath, marker, this.recoveryFileSystem)) ||
|
|
132
|
+
!(await canRemoveOwnedWorkspace(scope, path)))
|
|
133
|
+
throw new Error('Cannot acquire an unvalidated existing workspace');
|
|
134
|
+
if (marker.checkoutReady === false) {
|
|
135
|
+
await this.recoveryFileSystem.remove(path);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (!(await exists(join(path, '.git'), signal))) {
|
|
139
|
+
if (mode === WorkspaceMode.Branch)
|
|
140
|
+
throw new Error('Cannot reuse a branch checkout without Git metadata');
|
|
141
|
+
await this.recoveryFileSystem.remove(path);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return this.validateLegacyCheckout(marker, path, mode, signal);
|
|
145
|
+
}
|
|
146
|
+
async validateLegacyCheckout(marker, path, mode, signal) {
|
|
147
|
+
if (marker.checkoutReady !== undefined || mode !== WorkspaceMode.ReadOnly)
|
|
148
|
+
return true;
|
|
149
|
+
try {
|
|
150
|
+
await readCheckoutRevision(path, this.git, signal);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
signal.throwIfAborted();
|
|
155
|
+
// Legacy read-only clones may predate the durable completion flag.
|
|
156
|
+
await this.recoveryFileSystem.remove(path);
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
71
159
|
}
|
|
72
160
|
async recover(runs, options = {}) {
|
|
73
161
|
const scope = await recoveryScope(this.root, this.markerRoot);
|
|
74
162
|
if (scope === null)
|
|
75
163
|
return emptyRecovery();
|
|
76
|
-
const
|
|
164
|
+
const releaseFailures = [];
|
|
165
|
+
for (const [path, lock] of this.pendingReleases) {
|
|
166
|
+
if (await options.isPaused?.())
|
|
167
|
+
break;
|
|
168
|
+
try {
|
|
169
|
+
await this.releaseWorkspaceLock(path, lock);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
releaseFailures.push({ markerPath: path, path, message: errorMessage(error) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const names = await recoveryMarkerNames(this.markerRoot);
|
|
176
|
+
const workspaceIds = new Set(names.flatMap((name) => {
|
|
177
|
+
const match = /^(.*)\.(?:json(?:\.pending)?|lock|lease(?:\.owners)?)$/.exec(name);
|
|
178
|
+
return match !== null && isSafeWorkspaceId(match[1]) ? [match[1]] : [];
|
|
179
|
+
}));
|
|
77
180
|
let reclaimed = 0;
|
|
78
181
|
const reclaimedWorkItemIds = [];
|
|
79
|
-
const failures = [];
|
|
80
|
-
for (const
|
|
182
|
+
const failures = [...releaseFailures];
|
|
183
|
+
for (const workspaceId of workspaceIds) {
|
|
81
184
|
if (await options.isPaused?.())
|
|
82
185
|
break;
|
|
83
|
-
const result = await
|
|
84
|
-
|
|
186
|
+
const result = await recoverWorkspace({
|
|
187
|
+
workspaceId,
|
|
85
188
|
markerRoot: this.markerRoot,
|
|
86
189
|
scope,
|
|
87
190
|
runs,
|
|
88
191
|
fileSystem: this.recoveryFileSystem,
|
|
89
192
|
options,
|
|
193
|
+
locks: this.locks,
|
|
194
|
+
releaseLock: (path, lock) => this.releaseWorkspaceLock(path, lock),
|
|
90
195
|
});
|
|
91
196
|
reclaimed += result.reclaimed;
|
|
92
197
|
reclaimedWorkItemIds.push(...(result.reclaimedWorkItemIds ?? []));
|
|
@@ -113,13 +218,40 @@ async function recoveryMarkerNames(markerRoot) {
|
|
|
113
218
|
return [];
|
|
114
219
|
}
|
|
115
220
|
}
|
|
116
|
-
async function
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
221
|
+
async function validateLeasePaths(scope, path) {
|
|
222
|
+
for (const target of [
|
|
223
|
+
path,
|
|
224
|
+
`${path}.owners`,
|
|
225
|
+
`${path.slice(0, -'.lease'.length)}.lock`,
|
|
226
|
+
`${path.slice(0, -'.lease'.length)}.json.pending`,
|
|
227
|
+
]) {
|
|
228
|
+
const canonical = await canonicalPath(target);
|
|
229
|
+
if (canonical !== null && canonical !== join(scope.markerRoot, basename(target)))
|
|
230
|
+
throw new Error('Workspace lease artifacts must not be symbolic links');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function recoverWorkspace(input) {
|
|
234
|
+
const markerPath = join(input.markerRoot, `${input.workspaceId}.json`);
|
|
235
|
+
let marker = null;
|
|
121
236
|
try {
|
|
122
|
-
|
|
237
|
+
const lockPath = join(input.markerRoot, `${input.workspaceId}.lease`);
|
|
238
|
+
await validateLeasePaths(input.scope, lockPath);
|
|
239
|
+
const lock = await input.locks.tryAcquire(lockPath);
|
|
240
|
+
if (!lock.acquired)
|
|
241
|
+
return emptyRecovery();
|
|
242
|
+
try {
|
|
243
|
+
if (!(await recoverLegacyLock(join(input.markerRoot, `${input.workspaceId}.lock`), input.runs)))
|
|
244
|
+
return emptyRecovery();
|
|
245
|
+
await rm(`${markerPath}.pending`, { force: true });
|
|
246
|
+
marker = await readMarker(markerPath);
|
|
247
|
+
if (marker === null ||
|
|
248
|
+
!(await isOwnedWorkspace(input.scope, markerPath, marker, input.fileSystem)))
|
|
249
|
+
return emptyRecovery();
|
|
250
|
+
return await reclaimUnlockedMarker(input, markerPath, marker);
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
await input.releaseLock(lockPath, lock);
|
|
254
|
+
}
|
|
123
255
|
}
|
|
124
256
|
catch (error) {
|
|
125
257
|
return {
|
|
@@ -128,10 +260,35 @@ async function recoverMarker(input) {
|
|
|
128
260
|
};
|
|
129
261
|
}
|
|
130
262
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return
|
|
263
|
+
// Migration only: new acquisitions never create directory locks.
|
|
264
|
+
async function recoverLegacyLock(path, runs) {
|
|
265
|
+
if ((await canonicalPath(path)) === null)
|
|
266
|
+
return true;
|
|
267
|
+
const owner = await readLockOwner(path);
|
|
268
|
+
if (owner === undefined)
|
|
269
|
+
return false;
|
|
270
|
+
if (owner === null && runs.some(protectsWorkspace))
|
|
271
|
+
return false;
|
|
272
|
+
if (owner !== null && runs.some((run) => run.runId === owner && protectsWorkspace(run)))
|
|
273
|
+
return false;
|
|
274
|
+
if (owner !== null)
|
|
275
|
+
await unlink(join(path, owner));
|
|
276
|
+
try {
|
|
277
|
+
await rmdir(path);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (isNotFound(error))
|
|
282
|
+
return true;
|
|
283
|
+
if (isNotEmpty(error))
|
|
284
|
+
return false;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function protectsWorkspace(run) {
|
|
289
|
+
return isActiveRunStatus(run.status) || run.status === RunStatus.Ambiguous;
|
|
290
|
+
}
|
|
291
|
+
async function reclaimUnlockedMarker(input, markerPath, marker) {
|
|
135
292
|
if (input.runs.some((run) => (isActiveRunStatus(run.status) || run.status === RunStatus.Ambiguous) &&
|
|
136
293
|
(run.runId === marker.runId || run.workspace?.path === marker.path)))
|
|
137
294
|
return emptyRecovery();
|
|
@@ -143,6 +300,7 @@ async function reclaimOwnedMarker(input, markerPath, marker) {
|
|
|
143
300
|
await input.fileSystem.remove(marker.path);
|
|
144
301
|
if ((await input.options.onWorkspaceReclaimed?.(marker.workItemId)) === false)
|
|
145
302
|
return emptyRecovery();
|
|
303
|
+
await rm(`${markerPath}.pending`, { force: true });
|
|
146
304
|
await rm(markerPath, { force: true });
|
|
147
305
|
return {
|
|
148
306
|
reclaimed: 1,
|
|
@@ -182,7 +340,12 @@ function isOwnershipMarker(value) {
|
|
|
182
340
|
typeof marker.repositoryResourceId === 'string' &&
|
|
183
341
|
(marker.mode === WorkspaceMode.ReadOnly || marker.mode === WorkspaceMode.Branch) &&
|
|
184
342
|
typeof marker.workspaceId === 'string' &&
|
|
185
|
-
typeof marker.path === 'string'
|
|
343
|
+
typeof marker.path === 'string' &&
|
|
344
|
+
hasValidCheckoutState(marker));
|
|
345
|
+
}
|
|
346
|
+
function hasValidCheckoutState(marker) {
|
|
347
|
+
return ((marker.initialRevision === undefined || typeof marker.initialRevision === 'string') &&
|
|
348
|
+
(marker.checkoutReady === undefined || typeof marker.checkoutReady === 'boolean'));
|
|
186
349
|
}
|
|
187
350
|
async function isOwnedWorkspace(scope, markerPath, marker, fileSystem) {
|
|
188
351
|
if (!isSafeWorkspaceId(marker.workspaceId))
|
|
@@ -248,6 +411,75 @@ async function exists(path, signal) {
|
|
|
248
411
|
return false;
|
|
249
412
|
}
|
|
250
413
|
}
|
|
414
|
+
async function restoreReadOnlyWorkspace(path, revision, initialRevision, git, signal) {
|
|
415
|
+
if (revision !== undefined) {
|
|
416
|
+
await git(['-C', path, 'fetch', 'origin', revision], signal);
|
|
417
|
+
const fetched = await readGitRevision(path, 'FETCH_HEAD^{commit}', git, signal);
|
|
418
|
+
await git(['-C', path, 'checkout', '--detach', '--force', fetched], signal);
|
|
419
|
+
await git(['-C', path, 'reset', '--hard', fetched], signal);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
await git(['-C', path, 'reset', '--hard', initialRevision], signal);
|
|
423
|
+
}
|
|
424
|
+
async function readCheckoutRevision(path, git, signal) {
|
|
425
|
+
return readGitRevision(path, 'HEAD^{commit}', git, signal);
|
|
426
|
+
}
|
|
427
|
+
async function readGitRevision(path, reference, git, signal) {
|
|
428
|
+
const revision = await git(['-C', path, 'rev-parse', '--verify', reference], signal);
|
|
429
|
+
if (typeof revision !== 'string' || revision.trim() === '')
|
|
430
|
+
throw new Error('Git runner did not attest a valid workspace HEAD');
|
|
431
|
+
return revision.trim();
|
|
432
|
+
}
|
|
433
|
+
async function writeOwnershipMarker(path, marker, signal) {
|
|
434
|
+
const pending = `${path}.pending`;
|
|
435
|
+
try {
|
|
436
|
+
await writeFile(pending, JSON.stringify(marker), { encoding: 'utf8', signal });
|
|
437
|
+
signal.throwIfAborted();
|
|
438
|
+
await rename(pending, path);
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
await rm(pending, { force: true });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
async function acquireWorkspaceLock(locks, path, signal) {
|
|
445
|
+
while (true) {
|
|
446
|
+
signal.throwIfAborted();
|
|
447
|
+
const lock = await locks.tryAcquire(path);
|
|
448
|
+
if (lock.acquired)
|
|
449
|
+
return lock;
|
|
450
|
+
await waitForLock(signal);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
async function readLockOwner(path) {
|
|
454
|
+
try {
|
|
455
|
+
const owners = await readdir(path);
|
|
456
|
+
if (owners.length === 0)
|
|
457
|
+
return null;
|
|
458
|
+
return owners.length === 1 ? owners[0] : undefined;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (isNotFound(error))
|
|
462
|
+
return null;
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function isNotEmpty(error) {
|
|
467
|
+
return (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOTEMPTY');
|
|
468
|
+
}
|
|
469
|
+
function waitForLock(signal) {
|
|
470
|
+
return new Promise((resolve, reject) => {
|
|
471
|
+
const timer = setTimeout(done, 25);
|
|
472
|
+
const abort = () => {
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
reject(signal.reason);
|
|
475
|
+
};
|
|
476
|
+
function done() {
|
|
477
|
+
signal.removeEventListener('abort', abort);
|
|
478
|
+
resolve();
|
|
479
|
+
}
|
|
480
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
481
|
+
});
|
|
482
|
+
}
|
|
251
483
|
function slug(value) {
|
|
252
484
|
return value
|
|
253
485
|
.toLowerCase()
|