File size: 8,102 Bytes
253e783
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
import fs from "node:fs/promises";
import path from "node:path";
import { slugifyWorktreeTitle } from "../agents/worktrees/name.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { sha256HexPrefixCore } from "../infra/crypto-digest.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { withOpenClawStateLease } from "../state/openclaw-state-lease.js";
import {
  cloneProjectCheckout,
  ensureProjectCheckoutCommit,
  ProjectCloneError,
  refreshProjectCheckout,
} from "./project-clone-runtime.js";
import { parseProjectGitUrl } from "./project-git-url.js";
import {
  listProjectRegistry,
  registerClonedProjectRegistry,
  removeProjectCheckoutReference,
  resolveProjectCloneRefreshOwner,
  type ProjectRegistryRecord,
  withProjectCheckoutLifecycle,
} from "./project-registry.js";

const PROJECT_CLONE_LEASE_MS = 30_000;
const PROJECT_CLONE_WAIT_MS = 30_000;

function existingCanonicalProject(
  cfg: OpenClawConfig,
  canonicalUrl: string,
  options: OpenClawStateDatabaseOptions,
): ProjectRegistryRecord | undefined {
  return listProjectRegistry(cfg, options).find((project) => {
    const origin = project.originUrl ? parseProjectGitUrl(project.originUrl) : null;
    return origin?.url === canonicalUrl;
  });
}

/** Materializes and registers a project from an accepted GitHub remote. */
export async function materializeProjectClone(
  input: { cfg: OpenClawConfig; gitUrl: string; name?: string; requiredCommit?: string },
  options: OpenClawStateDatabaseOptions & {
    signal?: AbortSignal;
    timeoutMs?: number;
    token?: string;
  } = {},
): Promise<ProjectRegistryRecord> {
  const parsed = parseProjectGitUrl(input.gitUrl);
  if (!parsed) {
    throw new ProjectCloneError(
      "invalid_url",
      "Use a GitHub HTTPS or git@github.com repository URL. Local paths and file URLs are not accepted.",
    );
  }
  const env = options.env ?? process.env;
  const fingerprint = sha256HexPrefixCore(parsed.url, 16);
  return await withOpenClawStateLease(
    {
      scope: "projects.clone",
      key: fingerprint,
      database: { scope: "shared", options },
      leaseMs: PROJECT_CLONE_LEASE_MS,
      waitMs: PROJECT_CLONE_WAIT_MS,
      ...(options.signal ? { signal: options.signal } : {}),
      leaseLabel: "project clone lease",
      operationLabel: "projects.clone.lease",
    },
    async (lease) => {
      // Keep clone as the outer lease and take one candidate checkout lease at a time. A row that
      // moves roots while we wait must be retried under its new root instead of returned stale.
      while (true) {
        const candidate = existingCanonicalProject(input.cfg, parsed.url, options);
        if (!candidate) {
          break;
        }
        const existing = await withProjectCheckoutLifecycle(
          candidate.repoRoot,
          { ...options, signal: lease.signal },
          async (checkoutLease) => {
            const current = existingCanonicalProject(input.cfg, parsed.url, options);
            if (current?.repoRoot !== candidate.repoRoot) {
              return undefined;
            }
            if (input.requiredCommit) {
              await ensureProjectCheckoutCommit(
                { url: parsed.url, target: current.repoRoot, commit: input.requiredCommit },
                { ...options, env, signal: checkoutLease.signal },
              );
              checkoutLease.assertOwned();
            }
            return current;
          },
        );
        if (existing) {
          return existing;
        }
      }
      const displayName = input.name?.trim() || parsed.name;
      const directoryName = slugifyWorktreeTitle(displayName) ?? "project";
      const target = path.join(resolveStateDir(env), "projects", fingerprint, directoryName);
      await cloneProjectCheckout(
        { url: parsed.url, target, requiredCommit: input.requiredCommit },
        {
          env,
          signal: lease.signal,
          timeoutMs: options.timeoutMs,
          token: options.token,
        },
      );
      try {
        lease.assertOwned();
        return await registerClonedProjectRegistry(
          { path: target, name: displayName, originUrl: parsed.url },
          options,
        );
      } catch (error) {
        await fs.rm(target, { recursive: true, force: true }).catch(() => {});
        throw error;
      }
    },
  );
}

/** Refreshes an existing project clone while holding its checkout lifecycle lease. */
export async function refreshProjectClone(
  project: ProjectRegistryRecord,
  options: OpenClawStateDatabaseOptions & {
    env?: NodeJS.ProcessEnv;
    signal?: AbortSignal;
    timeoutMs?: number;
    token?: string;
  } = {},
): Promise<void> {
  if (project.source !== "cloned") {
    return;
  }
  await withProjectCheckoutLifecycle(project.repoRoot, options, async (lease) => {
    // Removal and registration share this lease. Re-read now so a queued stale record cannot
    // authorize network, object-store, or ref effects after checkout ownership changes.
    const current = resolveProjectCloneRefreshOwner(project, lease, options);
    if (!current) {
      throw new ProjectCloneError(
        "clone_failed",
        "This project is no longer a Gateway-managed clone. Reselect the repository and retry.",
      );
    }
    // Materialization validates the source URL; retries retain that registry identity.
    const originUrl = current.originUrl;
    if (!originUrl) {
      throw new ProjectCloneError(
        "invalid_url",
        "Saved project repository is invalid; select the repository and retry.",
      );
    }
    // The registry owns source identity; origin can be changed inside the shared checkout.
    await refreshProjectCheckout(
      { target: current.repoRoot, url: originUrl },
      { ...options, signal: lease.signal },
    );
    lease.assertOwned();
  });
}

async function resolveClonedProjectCheckout(
  project: ProjectRegistryRecord,
  options: { env?: NodeJS.ProcessEnv } = {},
): Promise<string> {
  if (project.source !== "cloned") {
    throw new ProjectCloneError(
      "clone_failed",
      "Only projects cloned by the Gateway can delete their checkout.",
    );
  }
  const managedRoot = await fs.realpath(path.join(resolveStateDir(options.env), "projects"));
  const checkout = await fs.realpath(project.repoRoot).catch(() => {
    throw new ProjectCloneError(
      "clone_failed",
      "The managed project checkout is already unavailable. Remove only its registry entry instead.",
    );
  });
  const relative = path.relative(managedRoot, checkout);
  const segments = relative.split(path.sep);
  if (
    !relative ||
    relative.startsWith(`..${path.sep}`) ||
    path.isAbsolute(relative) ||
    segments.length !== 2 ||
    !/^[a-f0-9]{16}$/u.test(segments[0] ?? "")
  ) {
    throw new ProjectCloneError(
      "clone_failed",
      "The cloned project is outside the Gateway-managed projects area, so its checkout was not deleted.",
    );
  }
  return checkout;
}

/** Removes one cloned-project reference and deletes its checkout only after the final reference. */
export async function removeClonedProjectCheckout(
  project: ProjectRegistryRecord,
  assertUnreferenced: () => void | Promise<void>,
  options: OpenClawStateDatabaseOptions & { env?: NodeJS.ProcessEnv } = {},
): Promise<boolean> {
  return await withProjectCheckoutLifecycle(project.repoRoot, options, async (lease) => {
    const checkout = await resolveClonedProjectCheckout(project, options);
    await assertUnreferenced();
    const result = removeProjectCheckoutReference(project, lease, options);
    if (result === "missing") {
      return false;
    }
    if (result === "changed") {
      throw new ProjectCloneError("clone_failed", "The cloned project changed before deletion.");
    }
    if (result === "remaining") {
      return true;
    }
    lease.assertOwned();
    await fs.rm(checkout, { recursive: true });
    await fs.rmdir(path.dirname(checkout)).catch(() => {});
    return true;
  });
}