@nowcrew/daemon 0.6.68 → 0.6.70

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 (72) hide show
  1. package/dist/agent-ability/materializer.js +30 -1
  2. package/dist/agent-ability/resolver.js +27 -16
  3. package/dist/agent-ability/runtime.js +3 -0
  4. package/dist/agent-ability/types.js +6 -11
  5. package/dist/automatic-install-target.js +15 -10
  6. package/dist/background-job.js +50 -0
  7. package/dist/computer-cli.js +10 -1
  8. package/dist/computer-service.js +41 -1
  9. package/dist/config.js +44 -0
  10. package/dist/console-formatter.js +76 -1
  11. package/dist/console.js +23 -4
  12. package/dist/daemon-migration-controller.js +16 -18
  13. package/dist/execution-protocol.js +0 -1
  14. package/dist/execution-runner.js +60 -11
  15. package/dist/i18n.js +2 -0
  16. package/dist/local-execution-prompt.js +32 -13
  17. package/dist/local-executor.js +107 -55
  18. package/dist/local-memory/index-budget.js +74 -0
  19. package/dist/local-memory/pending.js +111 -0
  20. package/dist/local-memory/runtime.js +87 -0
  21. package/dist/local-memory/types.js +1 -0
  22. package/dist/local-memory/write.js +448 -0
  23. package/dist/machine-info.js +3 -4
  24. package/dist/main.js +83 -0
  25. package/dist/managed-service-lifecycle.js +47 -2
  26. package/dist/managed-service-startup.js +2 -0
  27. package/dist/profile-layout.js +10 -4
  28. package/dist/project-dependencies.js +139 -124
  29. package/dist/project-dependency-lock.js +87 -0
  30. package/dist/project-dependency-manifest.js +2 -12
  31. package/dist/project-registry-wiring.js +1 -4
  32. package/dist/project-skills/controller.js +2 -0
  33. package/dist/project-skills/runtime-launch.js +10 -7
  34. package/dist/prompt.js +19 -1
  35. package/dist/provider-env.js +3 -21
  36. package/dist/repository-identity.js +45 -0
  37. package/dist/runner.js +4 -2
  38. package/dist/runtimes/codex-app-server-runner.js +55 -23
  39. package/dist/runtimes/codex-command-safety.js +75 -0
  40. package/dist/runtimes/codex-contract.js +20 -5
  41. package/dist/runtimes/codex-deepseek-config.js +7 -17
  42. package/dist/runtimes/codex-turn-retry.js +69 -0
  43. package/dist/runtimes/codex.js +4 -3
  44. package/dist/serve.js +68 -3
  45. package/dist/storage-config.js +175 -0
  46. package/dist/storage-control.js +193 -0
  47. package/dist/storage-copy.js +628 -0
  48. package/dist/storage-credential-guard.js +21 -0
  49. package/dist/storage-doctor.js +228 -0
  50. package/dist/storage-identity.js +29 -0
  51. package/dist/storage-layout.js +143 -0
  52. package/dist/storage-manifest.js +855 -0
  53. package/dist/storage-migration-controller.js +420 -0
  54. package/dist/storage-migration-manifest.js +105 -0
  55. package/dist/storage-migration-policy.js +100 -0
  56. package/dist/storage-runtime-controller.js +339 -0
  57. package/dist/storage-runtime-readiness.js +184 -0
  58. package/dist/storage-runtime.js +39 -0
  59. package/dist/storage-safe-fs.js +302 -0
  60. package/dist/storage-serve-adapter.js +65 -0
  61. package/dist/storage-status-delivery.js +209 -0
  62. package/dist/storage-status-replay.js +125 -0
  63. package/dist/storage-target-confirmation.js +129 -0
  64. package/dist/storage-worker-command.js +37 -0
  65. package/dist/storage-worker-executor.js +430 -0
  66. package/dist/storage-worker-handoff.js +151 -0
  67. package/dist/storage-worker-operation.js +284 -0
  68. package/dist/storage-worker-status.js +259 -0
  69. package/dist/supervised-runtime.js +3 -1
  70. package/dist/workspace.js +34 -14
  71. package/package.json +1 -1
  72. package/dist/self-training-workspace.js +0 -15
@@ -14,6 +14,7 @@ export async function assertManagedServiceStartup(input) {
14
14
  nodePath: input.nodePath,
15
15
  entryPath: input.entryPath,
16
16
  profileHome: input.daemonHome,
17
+ ...(input.environment === undefined ? {} : { environment: input.environment }),
17
18
  });
18
19
  const readRegistry = input.readManagedServices
19
20
  ?? (() => readManagedServiceRegistry({ userHome: input.userHome }));
@@ -42,6 +43,7 @@ export async function assertManagedServiceStartup(input) {
42
43
  nodePath: input.nodePath,
43
44
  entryPath: input.entryPath,
44
45
  profileHome: input.daemonHome,
46
+ ...(input.environment === undefined ? {} : { environment: input.environment }),
45
47
  legacyServiceId: true,
46
48
  });
47
49
  const legacyDescriptor = await readDescriptor(legacySpec);
@@ -2,6 +2,10 @@ import { posix, win32 } from "node:path";
2
2
  export const PROFILE_LAYOUT_VERSION = 1;
3
3
  const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]{0,47}$/u;
4
4
  const LOCAL_INSTANCE_NAME = /^[a-z0-9][a-z0-9_-]{0,39}$/u;
5
+ /** Accept the legacy `in` tag at the boundary, but never persist it in a new layout. */
6
+ export function normalizeProfileEnvironment(value) {
7
+ return value === "in" ? "production" : value;
8
+ }
5
9
  export function validateLocalInstanceName(value) {
6
10
  const normalized = value.trim().toLowerCase();
7
11
  if (!LOCAL_INSTANCE_NAME.test(normalized) || normalized.includes("..")) {
@@ -10,19 +14,21 @@ export function validateLocalInstanceName(value) {
10
14
  return normalized;
11
15
  }
12
16
  export function profileForLocalInstance(environment, localInstanceName) {
13
- const profile = `nw-${environment}-${validateLocalInstanceName(localInstanceName)}`;
17
+ const canonicalEnvironment = normalizeProfileEnvironment(environment);
18
+ const profile = `nw-${canonicalEnvironment}-${validateLocalInstanceName(localInstanceName)}`;
14
19
  if (!PROFILE_NAME.test(profile))
15
20
  throw new Error("derived daemon profile exceeds 48 characters");
16
21
  return profile;
17
22
  }
18
23
  export function profileLayout(input) {
24
+ const environment = normalizeProfileEnvironment(input.environment);
19
25
  const localInstanceName = validateLocalInstanceName(input.localInstanceName);
20
- const profile = profileForLocalInstance(input.environment, localInstanceName);
26
+ const profile = profileForLocalInstance(environment, localInstanceName);
21
27
  const paths = input.platform === "win32" ? win32 : posix;
22
- const base = `${input.environment}-${localInstanceName}`;
28
+ const base = `${environment}-${localInstanceName}`;
23
29
  return Object.freeze({
24
30
  layoutVersion: PROFILE_LAYOUT_VERSION,
25
- environment: input.environment,
31
+ environment,
26
32
  localInstanceName,
27
33
  profile,
28
34
  daemonHome: paths.join(input.userHome, ".crew", `daemon-${base}`),
@@ -1,10 +1,12 @@
1
1
  import { execFile } from "node:child_process";
2
- import { lstat, mkdir, readFile, rm } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { parse } from "yaml";
6
6
  import { validateAbilityGitHost } from "./agent-ability/resolver.js";
7
7
  import { dslog } from "./slog.js";
8
+ import { ProjectDependencyLockTimeoutError, withProjectDependencyLock, } from "./project-dependency-lock.js";
9
+ import { normalizeRepositoryIdentity, worktreeDirectoryName } from "./repository-identity.js";
8
10
  import { PROJECT_BRANCH, PROJECT_BRANCH_FORBIDDEN, PROJECT_MANIFEST_SOURCE_PATH, ProjectDependencyManifestSchema, } from "./project-dependency-manifest.js";
9
11
  const runFile = promisify(execFile);
10
12
  const PROJECT_MANIFEST_RELATIVE_PATH = PROJECT_MANIFEST_SOURCE_PATH;
@@ -14,12 +16,12 @@ export { ProjectDependencyManifestSchema } from "./project-dependency-manifest.j
14
16
  export class ProjectDependencyError extends Error {
15
17
  code;
16
18
  detail;
17
- projectId;
18
- constructor(code, detail = undefined, projectId) {
19
+ repositoryKey;
20
+ constructor(code, detail, repositoryKey) {
19
21
  super(code);
20
22
  this.code = code;
21
23
  this.detail = detail;
22
- this.projectId = projectId;
24
+ this.repositoryKey = repositoryKey;
23
25
  this.name = "ProjectDependencyError";
24
26
  }
25
27
  }
@@ -46,16 +48,23 @@ const defaultGit = {
46
48
  }
47
49
  },
48
50
  };
49
- const safeBranchDirectory = (branch) => branch
50
- .replaceAll("/", "__")
51
- .replace(/[^A-Za-z0-9._-]/gu, "_")
52
- .replace(/^[-.]+/u, "_")
53
- .slice(0, 128) || "default";
54
51
  const projectManifestPath = (trainingRoot) => resolve(trainingRoot, PROJECT_MANIFEST_RELATIVE_PATH);
55
52
  const isWithin = (root, target) => {
56
53
  const resolvedRoot = resolve(root);
57
54
  const resolvedTarget = resolve(target);
58
- return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${sep}`);
55
+ return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + sep);
56
+ };
57
+ const existingAncestorIsSymlink = async (root, target) => {
58
+ const resolvedRoot = resolve(root);
59
+ let current = resolve(target);
60
+ while (current !== resolvedRoot && isWithin(resolvedRoot, current)) {
61
+ const info = await lstat(current).catch(() => null);
62
+ if (info?.isSymbolicLink())
63
+ return true;
64
+ current = dirname(current);
65
+ }
66
+ const rootInfo = await lstat(resolvedRoot).catch(() => null);
67
+ return rootInfo?.isSymbolicLink() === true;
59
68
  };
60
69
  async function readProjectManifest(trainingRoot) {
61
70
  const path = projectManifestPath(trainingRoot);
@@ -78,127 +87,127 @@ async function readProjectManifest(trainingRoot) {
78
87
  throw new ProjectDependencyError("project_dependency_manifest_invalid", "project dependency manifest schema is invalid");
79
88
  }
80
89
  }
81
- async function resolveDefaultBranch(git, repo, projectId) {
90
+ async function resolveDefaultBranch(git, repo, repositoryKey) {
82
91
  let output;
83
92
  try {
84
93
  output = await git.run(["ls-remote", "--symref", repo, "HEAD"]);
85
94
  }
86
- catch (error) {
87
- throw new ProjectDependencyError("project_dependency_default_branch_unavailable", `cannot resolve remote default branch for ${projectId}`, projectId);
95
+ catch {
96
+ throw new ProjectDependencyError("project_dependency_default_branch_unavailable", "cannot resolve remote default branch", repositoryKey);
88
97
  }
89
98
  const branch = output.split(/\r?\n/u)
90
99
  .map((line) => line.match(/^ref:\s+refs\/heads\/([^\s]+)\s+HEAD$/u)?.[1])
91
100
  .find((value) => value !== undefined);
92
101
  if (branch === undefined || !PROJECT_BRANCH.test(branch) || PROJECT_BRANCH_FORBIDDEN.test(branch)) {
93
- throw new ProjectDependencyError("project_dependency_default_branch_unavailable", `remote default branch is unavailable for ${projectId}`, projectId);
102
+ throw new ProjectDependencyError("project_dependency_default_branch_unavailable", "remote default branch is unavailable", repositoryKey);
94
103
  }
95
104
  return branch;
96
105
  }
97
- async function checkoutProject(git, project, branch, target) {
98
- const targetInfo = await lstat(target).catch(() => null);
99
- if (targetInfo?.isSymbolicLink() || (targetInfo !== null && !targetInfo.isDirectory())) {
100
- throw new ProjectDependencyError("project_dependency_checkout_conflict", "checkout target is not a directory", project.id);
106
+ async function ensureWorktreeExclude(mainRoot) {
107
+ const excludePath = join(mainRoot, ".git", "info", "exclude");
108
+ const current = await readFile(excludePath, "utf8").catch(() => "");
109
+ if (current.split(/\r?\n/u).includes(".worktrees/"))
110
+ return;
111
+ const suffix = current.length === 0 || current.endsWith("\n") ? "" : "\n";
112
+ await writeFile(excludePath, current + suffix + ".worktrees\n", "utf8");
113
+ }
114
+ async function ensureMainClone(git, repo, repositoryKey, reposRoot) {
115
+ const mainRoot = resolve(reposRoot, repositoryKey);
116
+ if (!isWithin(reposRoot, mainRoot) || await existingAncestorIsSymlink(reposRoot, mainRoot)) {
117
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "repository path is unsafe", repositoryKey);
118
+ }
119
+ const info = await lstat(mainRoot).catch(() => null);
120
+ if (info?.isSymbolicLink() || (info !== null && !info.isDirectory())) {
121
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "main clone is not a directory", repositoryKey);
101
122
  }
102
- const created = targetInfo === null;
103
- if (created) {
104
- await mkdir(dirname(target), { recursive: true });
123
+ if (info === null) {
124
+ await mkdir(dirname(mainRoot), { recursive: true });
105
125
  try {
106
- await git.run(["clone", "--single-branch", "--branch", branch, project.repo, target]);
126
+ await git.run(["clone", "--no-checkout", repo, mainRoot]);
107
127
  }
108
- catch (error) {
109
- await rm(target, { recursive: true, force: true }).catch(() => { });
110
- throw new ProjectDependencyError("project_dependency_checkout_failed", error instanceof Error ? error.message : String(error), project.id);
128
+ catch {
129
+ await rm(mainRoot, { recursive: true, force: true }).catch(() => { });
130
+ throw new ProjectDependencyError("project_dependency_checkout_failed", "main clone failed", repositoryKey);
111
131
  }
112
132
  }
113
133
  else {
114
- let origin;
115
134
  try {
116
- origin = await git.run(["remote", "get-url", "origin"], target);
117
- if (origin !== project.repo) {
118
- throw new ProjectDependencyError("project_dependency_checkout_conflict", "checkout origin does not match manifest", project.id);
135
+ const origin = await git.run(["remote", "get-url", "origin"], mainRoot);
136
+ if (normalizeRepositoryIdentity(origin).repositoryKey !== repositoryKey) {
137
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "clone origin does not match repo", repositoryKey);
119
138
  }
120
- const dirty = await git.run(["status", "--porcelain"], target);
139
+ await ensureWorktreeExclude(mainRoot);
140
+ const dirty = await git.run(["status", "--porcelain"], mainRoot);
121
141
  if (dirty.length > 0) {
122
- throw new ProjectDependencyError("project_dependency_checkout_dirty", "checkout has local changes", project.id);
142
+ throw new ProjectDependencyError("project_dependency_checkout_dirty", "main clone has local changes", repositoryKey);
123
143
  }
124
- await git.run(["fetch", "--no-tags", "origin", branch], target);
125
- await git.run(["checkout", "--force", "-B", branch, `origin/${branch}`], target);
126
144
  }
127
145
  catch (error) {
128
146
  if (error instanceof ProjectDependencyError)
129
147
  throw error;
130
- throw new ProjectDependencyError("project_dependency_checkout_conflict", `cannot reuse checkout for ${project.id}`, project.id);
148
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "cannot inspect main clone", repositoryKey);
131
149
  }
132
150
  }
151
+ await ensureWorktreeExclude(mainRoot);
152
+ return mainRoot;
153
+ }
154
+ async function resolveCommit(git, project, branch, repositoryKey, mainRoot) {
133
155
  try {
134
156
  if (project.commit !== undefined) {
135
- await git.run(["checkout", "--force", "--detach", project.commit], target);
136
- }
137
- const resolvedCommit = await git.run(["rev-parse", "HEAD"], target);
138
- if (project.commit !== undefined && resolvedCommit !== project.commit) {
139
- throw new ProjectDependencyError("project_dependency_commit_mismatch", `project ${project.id} resolved to an unexpected commit`, project.id);
157
+ await git.run(["fetch", "--no-tags", "origin", project.commit], mainRoot);
158
+ const resolved = await git.run(["rev-parse", "FETCH_HEAD"], mainRoot);
159
+ if (resolved !== project.commit) {
160
+ throw new ProjectDependencyError("project_dependency_commit_mismatch", "requested commit was not resolved", repositoryKey);
161
+ }
162
+ return resolved;
140
163
  }
141
- return { resolvedCommit, created };
164
+ await git.run(["fetch", "--no-tags", "origin", branch], mainRoot);
165
+ return await git.run(["rev-parse", "origin/" + branch], mainRoot);
142
166
  }
143
- catch {
144
- if (project.commit !== undefined) {
145
- throw new ProjectDependencyError("project_dependency_commit_mismatch", `project ${project.id} could not be checked out at the requested commit`, project.id);
146
- }
147
- throw new ProjectDependencyError("project_dependency_checkout_failed", "checkout commit cannot be resolved", project.id);
167
+ catch (error) {
168
+ if (error instanceof ProjectDependencyError)
169
+ throw error;
170
+ throw new ProjectDependencyError("project_dependency_checkout_failed", "cannot resolve repository commit", repositoryKey);
148
171
  }
149
172
  }
150
- const existingAncestorIsSymlink = async (root, target) => {
151
- const resolvedRoot = resolve(root);
152
- let current = resolve(target);
153
- while (current !== resolvedRoot && isWithin(resolvedRoot, current)) {
154
- const info = await lstat(current).catch(() => null);
155
- if (info?.isSymbolicLink())
156
- return true;
157
- current = dirname(current);
173
+ async function ensureWorktree(git, mainRoot, repositoryKey, branch, resolvedCommit) {
174
+ const worktreeRoot = resolve(mainRoot, ".worktrees", worktreeDirectoryName(branch, resolvedCommit));
175
+ if (!isWithin(mainRoot, worktreeRoot) || await existingAncestorIsSymlink(mainRoot, worktreeRoot)) {
176
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "worktree path is unsafe", repositoryKey);
158
177
  }
159
- const rootInfo = await lstat(resolvedRoot).catch(() => null);
160
- return rootInfo?.isSymbolicLink() === true;
161
- };
162
- async function ensureRegistered(registry, project, root) {
163
- let registrations;
164
- try {
165
- registrations = await registry.list();
178
+ const info = await lstat(worktreeRoot).catch(() => null);
179
+ if (info?.isSymbolicLink() || (info !== null && !info.isDirectory())) {
180
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "worktree target is not a directory", repositoryKey);
166
181
  }
167
- catch {
168
- throw new ProjectDependencyError("project_dependency_registration_conflict", `cannot read project registry for ${project.id}`, project.id);
169
- }
170
- const existing = registrations.find((candidate) => candidate.projectId === project.id);
171
- if (existing === undefined) {
182
+ if (info !== null) {
172
183
  try {
173
- await registry.add(project.id, root);
184
+ const head = await git.run(["rev-parse", "HEAD"], worktreeRoot);
185
+ const dirty = await git.run(["status", "--porcelain"], worktreeRoot);
186
+ if (dirty.length > 0) {
187
+ throw new ProjectDependencyError("project_dependency_checkout_dirty", "worktree has local changes", repositoryKey);
188
+ }
189
+ if (head !== resolvedCommit) {
190
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "worktree commit does not match", repositoryKey);
191
+ }
192
+ return worktreeRoot;
174
193
  }
175
194
  catch (error) {
176
- throw new ProjectDependencyError("project_dependency_registration_conflict", `cannot register project ${project.id}`, project.id);
195
+ if (error instanceof ProjectDependencyError)
196
+ throw error;
197
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "cannot inspect worktree", repositoryKey);
177
198
  }
178
- return;
179
199
  }
180
- if (resolve(existing.root) === resolve(root))
181
- return;
182
- // A manifest branch change intentionally moves the logical project ID to the
183
- // new checkout. Keep the old registration if the replacement cannot finish.
200
+ await mkdir(dirname(worktreeRoot), { recursive: true });
184
201
  try {
185
- const removed = await registry.remove(project.id);
186
- if (!removed) {
187
- throw new Error("project registration disappeared before replacement");
188
- }
189
- try {
190
- await registry.add(project.id, root);
191
- }
192
- catch (error) {
193
- await registry.add(project.id, existing.root).catch(() => { });
194
- throw error;
195
- }
202
+ await git.run(["worktree", "add", "--detach", worktreeRoot, resolvedCommit], mainRoot);
203
+ return worktreeRoot;
196
204
  }
197
205
  catch {
198
- throw new ProjectDependencyError("project_dependency_registration_conflict", `cannot move project ${project.id} registration to the prepared checkout`, project.id);
206
+ await rm(worktreeRoot, { recursive: true, force: true }).catch(() => { });
207
+ throw new ProjectDependencyError("project_dependency_checkout_failed", "worktree creation failed", repositoryKey);
199
208
  }
200
209
  }
201
- export function createProjectDependencyProvisioner(options) {
210
+ export function createProjectDependencyProvisioner(options = {}) {
202
211
  const git = options.git ?? defaultGit;
203
212
  return {
204
213
  async prepare(input) {
@@ -206,60 +215,66 @@ export function createProjectDependencyProvisioner(options) {
206
215
  dslog("project_dependencies.manifest", "读取项目依赖清单", {
207
216
  training_root: input.trainingRoot,
208
217
  manifest_present: manifest !== null,
209
- requested_project_ids: JSON.stringify(input.projectIds),
210
- declared_project_ids: JSON.stringify(manifest?.projects.map((project) => project.id) ?? []),
218
+ declared_project_count: manifest?.projects.length ?? 0,
211
219
  });
212
- if (input.projectIds.length === 0)
220
+ if (manifest === null)
213
221
  return Object.freeze([]);
214
- if (manifest === null) {
215
- throw new ProjectDependencyError("project_dependency_manifest_missing", "project dependency manifest is missing from the active training package");
216
- }
217
222
  if (!isWithin(input.trainingRoot, projectManifestPath(input.trainingRoot))) {
218
223
  throw new ProjectDependencyError("project_dependency_manifest_invalid", "project manifest path escapes training root");
219
224
  }
220
- const byId = new Map(manifest.projects.map((project) => [project.id, project]));
221
- const requested = [...new Set(input.projectIds)];
222
- const results = [];
223
- for (const projectId of requested) {
224
- const project = byId.get(projectId);
225
- if (project === undefined) {
226
- throw new ProjectDependencyError("project_dependency_not_declared", `project ${projectId} is not declared by the active training package`, projectId);
227
- }
225
+ const grouped = new Map();
226
+ for (const project of manifest.projects) {
227
+ let identity;
228
228
  try {
229
229
  validateAbilityGitHost(project.repo);
230
+ identity = normalizeRepositoryIdentity(project.repo);
230
231
  }
231
232
  catch {
232
- throw new ProjectDependencyError("project_dependency_host_not_allowed", "repository host is not allowed", project.id);
233
- }
234
- const branchSource = project.branch === undefined ? "remote-default" : "manifest";
235
- const branch = project.branch ?? await resolveDefaultBranch(git, project.repo, project.id);
236
- const root = resolve(input.projectsRoot, project.id, safeBranchDirectory(branch));
237
- if (!isWithin(input.projectsRoot, root)) {
238
- throw new ProjectDependencyError("project_dependency_checkout_conflict", "checkout path escapes Projects root", project.id);
233
+ throw new ProjectDependencyError("project_dependency_host_not_allowed", "repository host is not allowed");
239
234
  }
240
- if (await existingAncestorIsSymlink(input.projectsRoot, root)) {
241
- throw new ProjectDependencyError("project_dependency_checkout_conflict", "checkout path contains a symbolic link", project.id);
242
- }
243
- const checkout = await checkoutProject(git, project, branch, root);
235
+ const existing = grouped.get(identity.repositoryKey);
236
+ if (existing === undefined)
237
+ grouped.set(identity.repositoryKey, { identity, projects: [project] });
238
+ else
239
+ existing.projects.push(project);
240
+ }
241
+ const results = [];
242
+ const lockRoot = input.lockRoot ?? join(dirname(resolve(input.reposRoot)), ".crew", "project-dependencies");
243
+ for (const { identity, projects } of grouped.values()) {
244
244
  try {
245
- await ensureRegistered(options.registry, project, root);
245
+ await withProjectDependencyLock(lockRoot, identity.repositoryKey, async () => {
246
+ const mainRoot = await ensureMainClone(git, projects[0].repo, identity.repositoryKey, input.reposRoot);
247
+ const seenVersions = new Set();
248
+ for (const project of projects) {
249
+ const branchSource = project.branch === undefined ? "remote-default" : "manifest";
250
+ const branch = project.branch ?? await resolveDefaultBranch(git, project.repo, identity.repositoryKey);
251
+ const resolvedCommit = await resolveCommit(git, project, branch, identity.repositoryKey, mainRoot);
252
+ const versionKey = branch + "\0" + resolvedCommit;
253
+ if (seenVersions.has(versionKey))
254
+ continue;
255
+ seenVersions.add(versionKey);
256
+ const localRoot = await ensureWorktree(git, mainRoot, identity.repositoryKey, branch, resolvedCommit);
257
+ results.push(Object.freeze({
258
+ repositoryKey: identity.repositoryKey,
259
+ displayName: identity.displayName,
260
+ repo: project.repo,
261
+ branch,
262
+ branchSource,
263
+ ...(project.commit === undefined ? {} : { requestedCommit: project.commit }),
264
+ resolvedCommit,
265
+ checkoutMode: "worktree",
266
+ localRoot,
267
+ summary: project.summary,
268
+ }));
269
+ }
270
+ });
246
271
  }
247
272
  catch (error) {
248
- if (checkout.created)
249
- await rm(root, { recursive: true, force: true }).catch(() => { });
273
+ if (error instanceof ProjectDependencyLockTimeoutError) {
274
+ throw new ProjectDependencyError("project_dependency_checkout_conflict", "project preparation lock timed out", identity.repositoryKey);
275
+ }
250
276
  throw error;
251
277
  }
252
- results.push(Object.freeze({
253
- projectId: project.id,
254
- repo: project.repo,
255
- branch,
256
- branchSource,
257
- ...(project.commit === undefined ? {} : { requestedCommit: project.commit }),
258
- resolvedCommit: checkout.resolvedCommit,
259
- checkoutMode: "shared",
260
- localRoot: root,
261
- summary: project.summary,
262
- }));
263
278
  }
264
279
  return Object.freeze(results);
265
280
  },
@@ -0,0 +1,87 @@
1
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { join, resolve } from "node:path";
3
+ const LOCK_RETRY_MS = 100;
4
+ const LOCK_WAIT_TIMEOUT_MS = 120_000;
5
+ const LOCK_STALE_MS = 15 * 60_000;
6
+ const localTails = new Map();
7
+ export class ProjectDependencyLockTimeoutError extends Error {
8
+ lockPath;
9
+ constructor(lockPath) {
10
+ super("project dependency preparation lock timed out");
11
+ this.lockPath = lockPath;
12
+ this.name = "ProjectDependencyLockTimeoutError";
13
+ }
14
+ }
15
+ const delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
16
+ const processIsAlive = (pid) => {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ return error.code !== "ESRCH";
23
+ }
24
+ };
25
+ async function staleLock(lockPath) {
26
+ const owner = await readFile(join(lockPath, "owner"), "utf8").catch(() => "");
27
+ const pid = Number.parseInt(owner.split(" ", 1)[0] ?? "", 10);
28
+ if (Number.isInteger(pid) && pid > 0)
29
+ return !processIsAlive(pid);
30
+ const lockInfo = await stat(lockPath).catch(() => null);
31
+ return lockInfo !== null && Date.now() - lockInfo.mtimeMs > LOCK_STALE_MS;
32
+ }
33
+ async function acquire(lockPath) {
34
+ const startedAt = Date.now();
35
+ while (true) {
36
+ try {
37
+ await mkdir(lockPath);
38
+ try {
39
+ await writeFile(join(lockPath, "owner"), `${process.pid} ${Date.now()}\n`, "utf8");
40
+ }
41
+ catch (error) {
42
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
43
+ throw error;
44
+ }
45
+ return async () => {
46
+ await rm(lockPath, { recursive: true, force: true });
47
+ };
48
+ }
49
+ catch (error) {
50
+ if (error.code !== "EEXIST")
51
+ throw error;
52
+ if (await staleLock(lockPath)) {
53
+ await rm(lockPath, { recursive: true, force: true }).catch(() => { });
54
+ continue;
55
+ }
56
+ if (Date.now() - startedAt >= LOCK_WAIT_TIMEOUT_MS) {
57
+ throw new ProjectDependencyLockTimeoutError(lockPath);
58
+ }
59
+ await delay(LOCK_RETRY_MS);
60
+ }
61
+ }
62
+ }
63
+ export async function withProjectDependencyLock(lockRoot, repositoryKey, operation) {
64
+ const root = resolve(lockRoot);
65
+ const lockPath = join(root, `${repositoryKey}.lock`);
66
+ await mkdir(root, { recursive: true });
67
+ const previous = localTails.get(lockPath) ?? Promise.resolve();
68
+ let releaseLocal;
69
+ const current = new Promise((resolveCurrent) => { releaseLocal = resolveCurrent; });
70
+ localTails.set(lockPath, current);
71
+ await previous;
72
+ let releaseFile;
73
+ try {
74
+ releaseFile = await acquire(lockPath);
75
+ return await operation();
76
+ }
77
+ finally {
78
+ try {
79
+ await releaseFile?.();
80
+ }
81
+ finally {
82
+ releaseLocal();
83
+ if (localTails.get(lockPath) === current)
84
+ localTails.delete(lockPath);
85
+ }
86
+ }
87
+ }
@@ -1,21 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import { AbilityRepositoryUrlSchema } from "./agent-ability/types.js";
3
- import { isProjectId } from "./project-skills/types.js";
4
- export const PROJECT_MANIFEST_SOURCE_PATH = "workspace/project-dependencies.yaml";
3
+ export const PROJECT_MANIFEST_SOURCE_PATH = "git/project-dependencies.yaml";
5
4
  export const PROJECT_BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/u;
6
5
  export const PROJECT_BRANCH_FORBIDDEN = /(?:^|\/)\.\.?(?:\/|$)|[\s\p{Cc}\\]/u;
7
6
  export const PROJECT_SUMMARY_MAX_LENGTH = 1_000;
8
7
  export const PROJECT_COMMIT = /^[a-f0-9]{40}$/u;
9
8
  const ProjectDependencySchema = z.object({
10
- id: z.string().min(1).max(64),
11
9
  repo: AbilityRepositoryUrlSchema,
12
10
  branch: z.string().min(1).max(256).optional(),
13
11
  commit: z.string().optional(),
14
12
  summary: z.string().min(1).max(PROJECT_SUMMARY_MAX_LENGTH),
15
13
  }).strict().superRefine((project, ctx) => {
16
- if (!isProjectId(project.id)) {
17
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "invalid project id" });
18
- }
19
14
  if (project.branch !== undefined
20
15
  && (!PROJECT_BRANCH.test(project.branch) || PROJECT_BRANCH_FORBIDDEN.test(project.branch))) {
21
16
  ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["branch"], message: "invalid project branch" });
@@ -27,9 +22,4 @@ const ProjectDependencySchema = z.object({
27
22
  export const ProjectDependencyManifestSchema = z.object({
28
23
  version: z.literal(1),
29
24
  projects: z.array(ProjectDependencySchema).min(1).max(32),
30
- }).strict().superRefine((manifest, ctx) => {
31
- const ids = manifest.projects.map((project) => project.id);
32
- if (new Set(ids).size !== ids.length) {
33
- ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["projects"], message: "duplicate project id" });
34
- }
35
- });
25
+ }).strict();
@@ -14,9 +14,6 @@ export function createProjectRegistryWiring(agentsRoot, input = {}) {
14
14
  ?? writableInjectedRegistry
15
15
  ?? createProjectRegistry(agentsRoot);
16
16
  const projectWorkspaceRegistry = input.projectWorkspaceRegistry ?? projectRegistry;
17
- if (input.projectDependencies !== undefined && input.projectRegistry === undefined) {
18
- throw new Error("project_dependency_injection_requires_project_registry");
19
- }
20
17
  if (input.projectWorkspaceRegistry !== undefined && projectWorkspaceRegistry !== projectRegistry) {
21
18
  throw new Error("project_registry_injection_requires_writable_registry");
22
19
  }
@@ -24,6 +21,6 @@ export function createProjectRegistryWiring(agentsRoot, input = {}) {
24
21
  projectRegistry,
25
22
  projectWorkspaceRegistry,
26
23
  projectDependencyProvisioner: input.projectDependencies
27
- ?? createProjectDependencyProvisioner({ registry: projectRegistry }),
24
+ ?? createProjectDependencyProvisioner(),
28
25
  });
29
26
  }
@@ -183,6 +183,8 @@ export function createProjectSkillsController(deps) {
183
183
  const parsed = ProjectCommandSchema.safeParse(input);
184
184
  if (!parsed.success)
185
185
  return { ok: false, error: "invalid_project_command" };
186
+ if (deps.isAdmissionClosed?.() === true)
187
+ return { ok: false, error: "storage_migration_quiescing" };
186
188
  try {
187
189
  assertCurrentEpoch(epochToken);
188
190
  }
@@ -57,6 +57,7 @@ export function projectSkillRuntimeLaunchFacts(runtime, root) {
57
57
  }
58
58
  /** Builds Runtime-native arrays while keeping v1 shared roots separate from v2 exact roots. */
59
59
  export async function projectSkillRuntimeDirectories(input) {
60
+ const trainingProjectRoots = input.trainingProjectRoots ?? [];
60
61
  const facts = projectSkillRuntimeLaunchFacts(input.runtime, input.runtimeRoot);
61
62
  const codexSkillRoots = input.runtime === "codex"
62
63
  && (input.runtimeRoot !== undefined || input.projectContext !== undefined)
@@ -79,20 +80,22 @@ export async function projectSkillRuntimeDirectories(input) {
79
80
  claudeAdditionalDirectories: await resolveOrderedUniqueClaudeDirectories([
80
81
  ...(input.projectContext?.secondary.map((project) => project.root) ?? []),
81
82
  facts.claudeAdditionalDirectory,
83
+ ...trainingProjectRoots,
82
84
  ...(input.abilitySkillRoot === undefined ? [] : [input.abilitySkillRoot]),
83
85
  ], input.projectContext?.primary === undefined ? [] : [input.projectContext.primary.root]),
84
86
  });
85
87
  }
86
88
  const claudeAdditionalDirectories = input.projectContext === undefined
87
- ? !input.projectSkillsPresent && input.abilitySkillRoot === undefined
88
- ? Object.freeze([])
89
- : await resolveOrderedUniqueClaudeDirectories([
90
- join(input.agentRoot, ".crew", "claude-skills"),
91
- input.agentRoot,
92
- ...(input.abilitySkillRoot === undefined ? [] : [input.abilitySkillRoot]),
93
- ])
89
+ ? await resolveOrderedUniqueClaudeDirectories([
90
+ ...(input.projectSkillsPresent || input.abilitySkillRoot !== undefined
91
+ ? [join(input.agentRoot, ".crew", "claude-skills"), input.agentRoot]
92
+ : []),
93
+ ...trainingProjectRoots,
94
+ ...(input.abilitySkillRoot === undefined ? [] : [input.abilitySkillRoot]),
95
+ ])
94
96
  : await resolveOrderedUniqueClaudeDirectories([
95
97
  ...input.projectContext.secondary.map((project) => project.root),
98
+ ...trainingProjectRoots,
96
99
  ...(input.projectSkillsPresent
97
100
  ? [join(input.agentRoot, ".crew", "claude-skills")]
98
101
  : []),