@nowcrew/daemon 0.6.3 → 0.6.5

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.
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { chmod, cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
3
- import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { parse } from "yaml";
5
5
  import { dslog } from "../slog.js";
6
6
  import { abilitySha256, abilityTreeDigest, collectAbilityFiles, resolveAbilityRelease, safeAbilityName, } from "./resolver.js";
@@ -10,6 +10,8 @@ import { parseSkillFrontmatter } from "../skill-frontmatter.js";
10
10
  import { isProjectSkillName, MAX_PROJECT_SKILL_DESCRIPTION_LENGTH, MAX_PROJECT_SKILL_FILE_BYTES, } from "../project-skills/types.js";
11
11
  import { createAgentProjectionCoordinator, runtimeProjectionLifetime, } from "../project-skills/agent-projection-coordinator.js";
12
12
  const exists = (path) => lstat(path).then(() => true, () => false);
13
+ const isRealDirectory = (path) => lstat(path)
14
+ .then((info) => info.isDirectory() && !info.isSymbolicLink(), () => false);
13
15
  const repositoryKey = (url) => createHash("sha256").update(url).digest("hex");
14
16
  const posixPath = (value) => value.split(sep).join("/");
15
17
  const sameSnapshot = (left, right) => JSON.stringify(left) === JSON.stringify(right);
@@ -208,6 +210,172 @@ const verifyCache = async (agentsRoot, ability) => {
208
210
  };
209
211
  const layerRelativePath = (path, layer) => path.startsWith(`${layer}/`) ? path.slice(layer.length + 1) : path;
210
212
  const readManagedCatalog = async (trainingRoot) => JSON.parse(await readFile(join(trainingRoot, "ACTIVE_RELEASE.json"), "utf8"));
213
+ const trainingSwitchJournalPath = (agentRoot) => join(agentRoot, ".training-switch.json");
214
+ const trainingRollbackRoot = (agentRoot) => join(agentRoot, ".training-rollback");
215
+ const trainingDiscardRoot = (agentRoot) => join(agentRoot, ".training-discard");
216
+ const writeTrainingSwitchJournal = async (agentRoot, journal) => {
217
+ const target = trainingSwitchJournalPath(agentRoot);
218
+ const staging = `${target}.next-${randomUUID()}`;
219
+ await writeFile(staging, JSON.stringify(journal), { encoding: "utf8", mode: 0o600 });
220
+ await rename(staging, target);
221
+ };
222
+ const readTrainingSwitchJournal = async (agentRoot) => {
223
+ const raw = await readFile(trainingSwitchJournalPath(agentRoot), "utf8").catch((error) => {
224
+ if (error.code === "ENOENT")
225
+ return null;
226
+ throw error;
227
+ });
228
+ if (raw === null)
229
+ return null;
230
+ let value;
231
+ try {
232
+ value = JSON.parse(raw);
233
+ }
234
+ catch {
235
+ throw new Error("ability_training_switch_journal_invalid");
236
+ }
237
+ if (typeof value !== "object" || value === null)
238
+ throw new Error("ability_training_switch_journal_invalid");
239
+ const journal = value;
240
+ if (journal.operation === "install"
241
+ && (journal.phase === "prepared" || journal.phase === "previous-renamed")
242
+ && typeof journal.stagingName === "string"
243
+ && basename(journal.stagingName) === journal.stagingName
244
+ && journal.stagingName.startsWith(".training-next-")) {
245
+ return { operation: "install", phase: journal.phase, stagingName: journal.stagingName };
246
+ }
247
+ if (journal.operation === "restore"
248
+ && (journal.phase === "prepared" || journal.phase === "current-moved" || journal.phase === "restored")) {
249
+ return { operation: "restore", phase: journal.phase };
250
+ }
251
+ throw new Error("ability_training_switch_journal_invalid");
252
+ };
253
+ const clearTrainingSwitchJournal = async (agentRoot) => {
254
+ await rm(trainingSwitchJournalPath(agentRoot), { force: true });
255
+ };
256
+ const assertManagedTrainingTreeIfPresent = async (root, errorCode) => {
257
+ if (!await exists(root))
258
+ return false;
259
+ if (!await isRealDirectory(root))
260
+ throw new Error(errorCode);
261
+ const catalog = await readManagedCatalog(root).catch(() => null);
262
+ if (catalog?.managedBy !== "nowcrew-agent-training")
263
+ throw new Error(errorCode);
264
+ return true;
265
+ };
266
+ const finishTrainingRestore = async (agentRoot) => {
267
+ const trainingRoot = join(agentRoot, "training");
268
+ const rollbackRoot = trainingRollbackRoot(agentRoot);
269
+ const discardRoot = trainingDiscardRoot(agentRoot);
270
+ const hasTraining = await assertManagedTrainingTreeIfPresent(trainingRoot, "ability_workspace_training_conflict");
271
+ const hasRollback = await assertManagedTrainingTreeIfPresent(rollbackRoot, "ability_training_rollback_invalid");
272
+ const hasDiscard = await assertManagedTrainingTreeIfPresent(discardRoot, "ability_training_discard_invalid");
273
+ if (hasTraining && hasRollback && !hasDiscard) {
274
+ await rename(trainingRoot, discardRoot);
275
+ await writeTrainingSwitchJournal(agentRoot, { operation: "restore", phase: "current-moved" });
276
+ }
277
+ if (!await exists(trainingRoot) && await exists(rollbackRoot)) {
278
+ await rename(rollbackRoot, trainingRoot);
279
+ await writeTrainingSwitchJournal(agentRoot, { operation: "restore", phase: "restored" });
280
+ }
281
+ if (!await exists(trainingRoot) && !await exists(rollbackRoot) && await exists(discardRoot)) {
282
+ await rename(discardRoot, trainingRoot);
283
+ }
284
+ if (!await exists(trainingRoot))
285
+ throw new Error("ability_training_restore_incomplete");
286
+ await removeManagedTree(discardRoot);
287
+ await clearTrainingSwitchJournal(agentRoot);
288
+ };
289
+ const recoverTrainingSwitch = async (agentRoot) => {
290
+ const journal = await readTrainingSwitchJournal(agentRoot);
291
+ if (journal === null)
292
+ return;
293
+ const trainingRoot = join(agentRoot, "training");
294
+ if (journal.operation === "restore") {
295
+ await finishTrainingRestore(agentRoot);
296
+ return;
297
+ }
298
+ const staging = join(agentRoot, journal.stagingName);
299
+ const rollbackRoot = trainingRollbackRoot(agentRoot);
300
+ await assertManagedTrainingTreeIfPresent(trainingRoot, "ability_workspace_training_conflict");
301
+ await assertManagedTrainingTreeIfPresent(staging, "ability_training_switch_staging_invalid");
302
+ await assertManagedTrainingTreeIfPresent(rollbackRoot, "ability_training_rollback_invalid");
303
+ if (await exists(trainingRoot)) {
304
+ await removeManagedTree(staging);
305
+ }
306
+ else if (await exists(staging)) {
307
+ await rename(staging, trainingRoot);
308
+ }
309
+ else if (await exists(rollbackRoot)) {
310
+ await rename(rollbackRoot, trainingRoot);
311
+ }
312
+ else {
313
+ throw new Error("ability_training_switch_recovery_failed");
314
+ }
315
+ await clearTrainingSwitchJournal(agentRoot);
316
+ };
317
+ const switchTrainingDirectory = async (agentRoot, staging) => {
318
+ await recoverTrainingSwitch(agentRoot);
319
+ const trainingRoot = join(agentRoot, "training");
320
+ const rollbackRoot = trainingRollbackRoot(agentRoot);
321
+ await assertManagedTrainingTreeIfPresent(staging, "ability_training_switch_staging_invalid");
322
+ await assertManagedTrainingTreeIfPresent(trainingRoot, "ability_workspace_training_conflict");
323
+ await assertManagedTrainingTreeIfPresent(rollbackRoot, "ability_training_rollback_invalid");
324
+ await removeManagedTree(rollbackRoot);
325
+ await writeTrainingSwitchJournal(agentRoot, {
326
+ operation: "install", phase: "prepared", stagingName: basename(staging),
327
+ });
328
+ try {
329
+ if (await exists(trainingRoot)) {
330
+ await rename(trainingRoot, rollbackRoot);
331
+ await writeTrainingSwitchJournal(agentRoot, {
332
+ operation: "install", phase: "previous-renamed", stagingName: basename(staging),
333
+ });
334
+ }
335
+ await rename(staging, trainingRoot);
336
+ await clearTrainingSwitchJournal(agentRoot);
337
+ }
338
+ catch (error) {
339
+ await recoverTrainingSwitch(agentRoot).catch(() => { });
340
+ throw error;
341
+ }
342
+ };
343
+ const restoreTrainingRollback = async (agentRoot, ability) => {
344
+ await recoverTrainingSwitch(agentRoot);
345
+ const currentCatalog = await readManagedCatalog(join(agentRoot, "training")).catch(() => null);
346
+ const rollbackRoot = trainingRollbackRoot(agentRoot);
347
+ if (!await isRealDirectory(rollbackRoot))
348
+ return null;
349
+ const catalog = await readManagedCatalog(rollbackRoot).catch(() => null);
350
+ if (catalog?.managedBy !== "nowcrew-agent-training"
351
+ || catalog.releaseId !== ability.releaseId
352
+ || catalog.rootCommit !== ability.rootCommit
353
+ || catalog.artifactDigest !== ability.artifactDigest)
354
+ return null;
355
+ await removeManagedTree(trainingDiscardRoot(agentRoot));
356
+ await writeTrainingSwitchJournal(agentRoot, { operation: "restore", phase: "prepared" });
357
+ await finishTrainingRestore(agentRoot);
358
+ dslog("ability.workspace.rollback_restored", "Previous Agent training directory restored locally", {
359
+ level: "INFO",
360
+ from_release_id: typeof currentCatalog?.releaseId === "string" ? currentCatalog.releaseId : "unknown",
361
+ release_id: ability.releaseId,
362
+ root_commit: ability.rootCommit,
363
+ artifact_digest: ability.artifactDigest,
364
+ });
365
+ return join(agentRoot, "training");
366
+ };
367
+ const activeTrainingRoot = async (agentsRoot, handle, ability) => {
368
+ const trainingRoot = join(agentsRoot, handle, "training");
369
+ if (!await isRealDirectory(trainingRoot))
370
+ return null;
371
+ const catalog = await readManagedCatalog(trainingRoot).catch(() => null);
372
+ return catalog?.managedBy === "nowcrew-agent-training"
373
+ && catalog.releaseId === ability.releaseId
374
+ && catalog.rootCommit === ability.rootCommit
375
+ && catalog.artifactDigest === ability.artifactDigest
376
+ ? trainingRoot
377
+ : null;
378
+ };
211
379
  const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
212
380
  let verified;
213
381
  try {
@@ -238,6 +406,8 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
238
406
  }));
239
407
  let previousCatalog = null;
240
408
  if (await exists(trainingRoot)) {
409
+ if (!await isRealDirectory(trainingRoot))
410
+ throw new Error("ability_workspace_training_conflict");
241
411
  previousCatalog = await readManagedCatalog(trainingRoot).catch(() => null);
242
412
  if (previousCatalog?.managedBy !== "nowcrew-agent-training") {
243
413
  throw new Error("ability_workspace_training_conflict");
@@ -249,7 +419,6 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
249
419
  }
250
420
  await mkdir(agentRoot, { recursive: true });
251
421
  const staging = join(agentRoot, `.training-next-${ability.releaseId}-${randomUUID()}`);
252
- const backup = join(agentRoot, `.training-previous-${ability.releaseId}-${randomUUID()}`);
253
422
  await mkdir(staging, { recursive: true, mode: 0o700 });
254
423
  try {
255
424
  const instructionsTarget = join(staging, "instructions", layerRelativePath(ability.instructions.path, "instructions"));
@@ -323,7 +492,7 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
323
492
  await writableTree(join(staging, "workspace", abilityWorkspaceProjectionPath(asset.target)));
324
493
  }
325
494
  }
326
- await switchDirectory(trainingRoot, staging, backup);
495
+ await switchTrainingDirectory(agentRoot, staging);
327
496
  }
328
497
  catch (error) {
329
498
  await removeManagedTree(staging).catch(() => { });
@@ -331,6 +500,14 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
331
500
  }
332
501
  return trainingRoot;
333
502
  };
503
+ const ensureWorkspaceTraining = async (agentsRoot, handle, ability) => {
504
+ const agentRoot = join(agentsRoot, handle);
505
+ await mkdir(agentRoot, { recursive: true });
506
+ await recoverTrainingSwitch(agentRoot);
507
+ return await activeTrainingRoot(agentsRoot, handle, ability)
508
+ ?? await restoreTrainingRollback(agentRoot, ability)
509
+ ?? await materializeWorkspaceTraining(agentsRoot, handle, ability);
510
+ };
334
511
  const projectWorkspaceTraining = async (agentsRoot, handle, ability, trainingRoot) => {
335
512
  const agentRoot = join(agentsRoot, handle);
336
513
  await projectNativeSkills(agentRoot, trainingRoot, ability.skills);
@@ -345,7 +522,7 @@ export function createAgentAbilityMaterializer(expectedAgentsRoot, coordinator =
345
522
  const apply = async (agentsRoot, handle, ability) => {
346
523
  if (agentsRoot !== expectedAgentsRoot)
347
524
  throw new Error("ability_agents_root_mismatch");
348
- const trainingRoot = await materializeWorkspaceTraining(agentsRoot, handle, ability);
525
+ const trainingRoot = await ensureWorkspaceTraining(agentsRoot, handle, ability);
349
526
  await projectWorkspaceTraining(agentsRoot, handle, ability, trainingRoot);
350
527
  return { directory: "training", releaseId: ability.releaseId, artifactDigest: ability.artifactDigest };
351
528
  };
@@ -378,7 +555,8 @@ export function createAgentAbilityMaterializer(expectedAgentsRoot, coordinator =
378
555
  return coordinator.runExclusiveUntil(expectedAgentsRoot, handle, async () => {
379
556
  const started = Date.now();
380
557
  try {
381
- const trainingRoot = await apply(agentsRoot, handle, ability).then(() => join(agentsRoot, handle, "training"));
558
+ const trainingRoot = await ensureWorkspaceTraining(agentsRoot, handle, ability);
559
+ await projectWorkspaceTraining(agentsRoot, handle, ability, trainingRoot);
382
560
  const context = await loadAgentAbilityRuntimeContext(join(agentsRoot, handle), trainingRoot, ability);
383
561
  dslog("ability.execution.materialized", "Agent ability release materialized", {
384
562
  level: "INFO", agent_handle: handle, release_id: ability.releaseId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",