@nowcrew/daemon 0.6.4 → 0.6.6

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,8 +210,166 @@ 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
+ const discardRoot = trainingDiscardRoot(agentRoot);
356
+ await assertManagedTrainingTreeIfPresent(discardRoot, "ability_training_discard_invalid");
357
+ await removeManagedTree(discardRoot);
358
+ await writeTrainingSwitchJournal(agentRoot, { operation: "restore", phase: "prepared" });
359
+ await finishTrainingRestore(agentRoot);
360
+ dslog("ability.workspace.rollback_restored", "Previous Agent training directory restored locally", {
361
+ level: "INFO",
362
+ from_release_id: typeof currentCatalog?.releaseId === "string" ? currentCatalog.releaseId : "unknown",
363
+ release_id: ability.releaseId,
364
+ root_commit: ability.rootCommit,
365
+ artifact_digest: ability.artifactDigest,
366
+ });
367
+ return join(agentRoot, "training");
368
+ };
211
369
  const activeTrainingRoot = async (agentsRoot, handle, ability) => {
212
370
  const trainingRoot = join(agentsRoot, handle, "training");
371
+ if (!await isRealDirectory(trainingRoot))
372
+ return null;
213
373
  const catalog = await readManagedCatalog(trainingRoot).catch(() => null);
214
374
  return catalog?.managedBy === "nowcrew-agent-training"
215
375
  && catalog.releaseId === ability.releaseId
@@ -248,6 +408,8 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
248
408
  }));
249
409
  let previousCatalog = null;
250
410
  if (await exists(trainingRoot)) {
411
+ if (!await isRealDirectory(trainingRoot))
412
+ throw new Error("ability_workspace_training_conflict");
251
413
  previousCatalog = await readManagedCatalog(trainingRoot).catch(() => null);
252
414
  if (previousCatalog?.managedBy !== "nowcrew-agent-training") {
253
415
  throw new Error("ability_workspace_training_conflict");
@@ -259,7 +421,6 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
259
421
  }
260
422
  await mkdir(agentRoot, { recursive: true });
261
423
  const staging = join(agentRoot, `.training-next-${ability.releaseId}-${randomUUID()}`);
262
- const backup = join(agentRoot, `.training-previous-${ability.releaseId}-${randomUUID()}`);
263
424
  await mkdir(staging, { recursive: true, mode: 0o700 });
264
425
  try {
265
426
  const instructionsTarget = join(staging, "instructions", layerRelativePath(ability.instructions.path, "instructions"));
@@ -333,7 +494,7 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
333
494
  await writableTree(join(staging, "workspace", abilityWorkspaceProjectionPath(asset.target)));
334
495
  }
335
496
  }
336
- await switchDirectory(trainingRoot, staging, backup);
497
+ await switchTrainingDirectory(agentRoot, staging);
337
498
  }
338
499
  catch (error) {
339
500
  await removeManagedTree(staging).catch(() => { });
@@ -341,6 +502,14 @@ const materializeWorkspaceTraining = async (agentsRoot, handle, ability) => {
341
502
  }
342
503
  return trainingRoot;
343
504
  };
505
+ const ensureWorkspaceTraining = async (agentsRoot, handle, ability) => {
506
+ const agentRoot = join(agentsRoot, handle);
507
+ await mkdir(agentRoot, { recursive: true });
508
+ await recoverTrainingSwitch(agentRoot);
509
+ return await activeTrainingRoot(agentsRoot, handle, ability)
510
+ ?? await restoreTrainingRollback(agentRoot, ability)
511
+ ?? await materializeWorkspaceTraining(agentsRoot, handle, ability);
512
+ };
344
513
  const projectWorkspaceTraining = async (agentsRoot, handle, ability, trainingRoot) => {
345
514
  const agentRoot = join(agentsRoot, handle);
346
515
  await projectNativeSkills(agentRoot, trainingRoot, ability.skills);
@@ -355,7 +524,7 @@ export function createAgentAbilityMaterializer(expectedAgentsRoot, coordinator =
355
524
  const apply = async (agentsRoot, handle, ability) => {
356
525
  if (agentsRoot !== expectedAgentsRoot)
357
526
  throw new Error("ability_agents_root_mismatch");
358
- const trainingRoot = await materializeWorkspaceTraining(agentsRoot, handle, ability);
527
+ const trainingRoot = await ensureWorkspaceTraining(agentsRoot, handle, ability);
359
528
  await projectWorkspaceTraining(agentsRoot, handle, ability, trainingRoot);
360
529
  return { directory: "training", releaseId: ability.releaseId, artifactDigest: ability.artifactDigest };
361
530
  };
@@ -388,16 +557,8 @@ export function createAgentAbilityMaterializer(expectedAgentsRoot, coordinator =
388
557
  return coordinator.runExclusiveUntil(expectedAgentsRoot, handle, async () => {
389
558
  const started = Date.now();
390
559
  try {
391
- const existingTrainingRoot = await activeTrainingRoot(agentsRoot, handle, ability);
392
- let trainingRoot;
393
- if (existingTrainingRoot !== null) {
394
- trainingRoot = existingTrainingRoot;
395
- await projectWorkspaceTraining(agentsRoot, handle, ability, existingTrainingRoot);
396
- }
397
- else {
398
- await apply(agentsRoot, handle, ability);
399
- trainingRoot = join(agentsRoot, handle, "training");
400
- }
560
+ const trainingRoot = await ensureWorkspaceTraining(agentsRoot, handle, ability);
561
+ await projectWorkspaceTraining(agentsRoot, handle, ability, trainingRoot);
401
562
  const context = await loadAgentAbilityRuntimeContext(join(agentsRoot, handle), trainingRoot, ability);
402
563
  dslog("ability.execution.materialized", "Agent ability release materialized", {
403
564
  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.4",
3
+ "version": "0.6.6",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",