@tangle-network/agent-profile-materialize 0.5.0 → 0.5.1

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/index.d.ts CHANGED
@@ -60,9 +60,17 @@ export interface WorkspacePlanReceipt {
60
60
  unsupported: Unsupported[];
61
61
  written: string[];
62
62
  }
63
+ /** The plan was applied, but one or more private staging files remain. */
64
+ export declare class WorkspacePlanCleanupError extends AggregateError {
65
+ readonly receipt: WorkspacePlanReceipt;
66
+ readonly temporaryPaths: readonly string[];
67
+ constructor(errors: readonly unknown[], receipt: WorkspacePlanReceipt, temporaryPaths: readonly string[]);
68
+ }
63
69
  export interface ApplyWorkspacePlanOptions {
64
70
  /** Plan files consumed in memory by the runtime and never persisted to cwd. */
65
71
  runtimeOnlyFiles?: readonly string[];
72
+ /** Existing workspace paths are either exact idempotent inputs or forbidden. */
73
+ existingFiles?: "allow-identical" | "reject";
66
74
  }
67
75
  /** Fail when a profile asks for controls the chosen harness cannot execute. */
68
76
  export declare function assertWorkspacePlanSupported(plan: Pick<WorkspacePlan, "unsupported">): void;
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  * cli-bridge and agent-dev-container — this is the single source those re-export.
23
23
  */
24
24
  import { createHash, randomUUID } from "node:crypto";
25
- import { closeSync, fchmodSync, constants as fsConstants, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
25
+ import { closeSync, fchmodSync, constants as fsConstants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
26
26
  import { basename, dirname, isAbsolute, join, relative, resolve, sep, win32, } from "node:path";
27
27
  import { agentCandidateProfileActivationSchema, agentCandidateProfilePlanEvidenceSchema, agentCandidateProfilePlanMaterialSchema, agentCandidateProfileSchema, agentProfileSchema, canonicalCandidateBytes, canonicalCandidateDigest, resolveNativeWebTools, } from "@tangle-network/agent-interface";
28
28
  /** Resolve inline bytes already present on the profile. Remote resources must
@@ -37,6 +37,17 @@ function resolveInlineRef(ref) {
37
37
  }
38
38
  return null; // github / unresolved
39
39
  }
40
+ /** The plan was applied, but one or more private staging files remain. */
41
+ export class WorkspacePlanCleanupError extends AggregateError {
42
+ receipt;
43
+ temporaryPaths;
44
+ constructor(errors, receipt, temporaryPaths) {
45
+ super(errors, "Applied workspace plan but failed to remove temporary files");
46
+ this.name = "WorkspacePlanCleanupError";
47
+ this.receipt = receipt;
48
+ this.temporaryPaths = Object.freeze([...temporaryPaths]);
49
+ }
50
+ }
40
51
  /** Fail when a profile asks for controls the chosen harness cannot execute. */
41
52
  export function assertWorkspacePlanSupported(plan) {
42
53
  if (plan.unsupported.length > 0) {
@@ -952,6 +963,10 @@ export function applyAgentCandidateWorkspacePlan(plan, workspaceDir, targetWorks
952
963
  export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
953
964
  const workspaceRoot = resolve(workspaceDir);
954
965
  const runtimeOnlyFiles = new Set((options.runtimeOnlyFiles ?? []).map(normalizeWorkspaceRelativePath));
966
+ const existingFiles = options.existingFiles ?? "allow-identical";
967
+ if (existingFiles !== "allow-identical" && existingFiles !== "reject") {
968
+ throw new Error(`Unsupported existing workspace file policy: ${existingFiles}`);
969
+ }
955
970
  const files = normalizePlanFiles(plan.files)
956
971
  .filter((file) => !runtimeOnlyFiles.has(file.relPath))
957
972
  .map((file) => ({
@@ -962,24 +977,77 @@ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
962
977
  // existing symlink cannot leave a partially materialized workspace.
963
978
  for (const file of files) {
964
979
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
965
- if (workspaceFileExists(file.absPath) && !workspaceFileMatches(file)) {
980
+ if (workspaceFileExists(file.absPath) &&
981
+ (existingFiles === "reject" || !workspaceFileMatches(file))) {
966
982
  throw new Error(`Refusing to replace existing workspace file: ${file.relPath}`);
967
983
  }
968
984
  }
985
+ // Parent directories remain on failure because they may contain concurrent
986
+ // workspace state that this call does not own.
969
987
  for (const file of files) {
970
988
  mkdirSync(dirname(file.absPath), { recursive: true });
971
989
  }
972
990
  for (const file of files) {
973
991
  assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
974
992
  }
975
- const written = [];
976
- for (const file of files) {
977
- if (!workspaceFileExists(file.absPath)) {
978
- writeWorkspaceFile(workspaceRoot, file);
993
+ const prepared = [];
994
+ // Prepared temp links stay alive through publication and rollback. Their
995
+ // inodes distinguish files created here from concurrent replacements.
996
+ try {
997
+ for (const file of files) {
998
+ if (existingFiles === "allow-identical" &&
999
+ workspaceFileExists(file.absPath) &&
1000
+ workspaceFileMatches(file)) {
1001
+ continue;
1002
+ }
1003
+ prepared.push(prepareWorkspaceFile(workspaceRoot, file));
1004
+ }
1005
+ for (const entry of prepared) {
1006
+ assertNoSymlinkPathComponents(workspaceRoot, entry.file.absPath);
1007
+ try {
1008
+ linkSync(entry.temporaryPath, entry.file.absPath);
1009
+ entry.published = true;
1010
+ }
1011
+ catch (error) {
1012
+ if (isFsError(error, "EEXIST") &&
1013
+ existingFiles === "allow-identical" &&
1014
+ workspaceFileMatches(entry.file)) {
1015
+ continue;
1016
+ }
1017
+ if (isFsError(error, "EEXIST")) {
1018
+ throw new Error(`Refusing to replace existing workspace file: ${entry.file.relPath}`, { cause: error });
1019
+ }
1020
+ throw error;
1021
+ }
979
1022
  }
980
- written.push(file.relPath);
981
1023
  }
982
- return {
1024
+ catch (error) {
1025
+ const cleanupErrors = [];
1026
+ for (const entry of [...prepared].reverse()) {
1027
+ try {
1028
+ if (entry.published &&
1029
+ workspacePathHasIdentity(entry.file.absPath, entry.identity)) {
1030
+ unlinkSync(entry.file.absPath);
1031
+ }
1032
+ }
1033
+ catch (cleanupError) {
1034
+ cleanupErrors.push(cleanupError);
1035
+ }
1036
+ try {
1037
+ if (workspacePathHasIdentity(entry.temporaryPath, entry.identity)) {
1038
+ unlinkSync(entry.temporaryPath);
1039
+ }
1040
+ }
1041
+ catch (cleanupError) {
1042
+ cleanupErrors.push(cleanupError);
1043
+ }
1044
+ }
1045
+ if (cleanupErrors.length > 0) {
1046
+ throw new AggregateError([error, ...cleanupErrors], "Failed to apply workspace plan and roll back published files");
1047
+ }
1048
+ throw error;
1049
+ }
1050
+ const receipt = {
983
1051
  workspacePlanDigest: hashWorkspacePlan(plan),
984
1052
  env: plan.env,
985
1053
  flags: plan.flags,
@@ -987,8 +1055,25 @@ export function applyWorkspacePlan(plan, workspaceDir, options = {}) {
987
1055
  ? {}
988
1056
  : { systemPrompt: plan.systemPrompt }),
989
1057
  unsupported: plan.unsupported,
990
- written,
1058
+ written: files.map((file) => file.relPath),
991
1059
  };
1060
+ const cleanupErrors = [];
1061
+ const temporaryPaths = [];
1062
+ for (const entry of prepared) {
1063
+ try {
1064
+ unlinkSync(entry.temporaryPath);
1065
+ }
1066
+ catch (error) {
1067
+ if (!isFsError(error, "ENOENT")) {
1068
+ cleanupErrors.push(error);
1069
+ temporaryPaths.push(entry.temporaryPath);
1070
+ }
1071
+ }
1072
+ }
1073
+ if (cleanupErrors.length > 0) {
1074
+ throw new WorkspacePlanCleanupError(cleanupErrors, receipt, temporaryPaths);
1075
+ }
1076
+ return receipt;
992
1077
  }
993
1078
  function workspaceFileExists(pathValue) {
994
1079
  try {
@@ -1007,6 +1092,59 @@ function workspaceFileMatches(file) {
1007
1092
  (stats.mode & 0o777) === file.mode &&
1008
1093
  readFileSync(file.absPath, "utf8") === file.content);
1009
1094
  }
1095
+ function prepareWorkspaceFile(workspaceRoot, file) {
1096
+ assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1097
+ const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
1098
+ let descriptor;
1099
+ try {
1100
+ descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
1101
+ writeFileSync(descriptor, file.content, "utf8");
1102
+ fchmodSync(descriptor, file.mode);
1103
+ const stats = fstatSync(descriptor);
1104
+ const descriptorToClose = descriptor;
1105
+ descriptor = undefined;
1106
+ closeSync(descriptorToClose);
1107
+ return {
1108
+ file,
1109
+ temporaryPath,
1110
+ identity: { device: stats.dev, inode: stats.ino },
1111
+ published: false,
1112
+ };
1113
+ }
1114
+ catch (error) {
1115
+ const cleanupErrors = [];
1116
+ if (descriptor !== undefined) {
1117
+ try {
1118
+ closeSync(descriptor);
1119
+ }
1120
+ catch (cleanupError) {
1121
+ cleanupErrors.push(cleanupError);
1122
+ }
1123
+ }
1124
+ try {
1125
+ unlinkSync(temporaryPath);
1126
+ }
1127
+ catch (cleanupError) {
1128
+ if (!isFsError(cleanupError, "ENOENT"))
1129
+ cleanupErrors.push(cleanupError);
1130
+ }
1131
+ if (cleanupErrors.length > 0) {
1132
+ throw new AggregateError([error, ...cleanupErrors], `Failed to prepare workspace file: ${file.relPath}`);
1133
+ }
1134
+ throw error;
1135
+ }
1136
+ }
1137
+ function workspacePathHasIdentity(pathValue, identity) {
1138
+ try {
1139
+ const stats = lstatSync(pathValue);
1140
+ return stats.dev === identity.device && stats.ino === identity.inode;
1141
+ }
1142
+ catch (error) {
1143
+ if (isFsError(error, "ENOENT"))
1144
+ return false;
1145
+ throw error;
1146
+ }
1147
+ }
1010
1148
  function resolveWorkspaceFilePath(workspaceRoot, relativePath) {
1011
1149
  const targetPath = resolve(workspaceRoot, ...relativePath.split("/"));
1012
1150
  const targetRelativePath = relative(workspaceRoot, targetPath);
@@ -1046,40 +1184,6 @@ function assertNoSymlinkPathComponents(workspaceRoot, targetPath) {
1046
1184
  }
1047
1185
  }
1048
1186
  }
1049
- function writeWorkspaceFile(workspaceRoot, file) {
1050
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1051
- const temporaryPath = join(dirname(file.absPath), `.agent-profile-${randomUUID()}.tmp`);
1052
- let descriptor;
1053
- try {
1054
- descriptor = openSync(temporaryPath, SAFE_WRITE_FLAGS, 0o600);
1055
- writeFileSync(descriptor, file.content, "utf8");
1056
- fchmodSync(descriptor, file.mode);
1057
- closeSync(descriptor);
1058
- descriptor = undefined;
1059
- assertNoSymlinkPathComponents(workspaceRoot, file.absPath);
1060
- try {
1061
- linkSync(temporaryPath, file.absPath);
1062
- }
1063
- catch (error) {
1064
- if (!isFsError(error, "EEXIST") || !workspaceFileMatches(file)) {
1065
- throw error;
1066
- }
1067
- }
1068
- unlinkSync(temporaryPath);
1069
- }
1070
- catch (error) {
1071
- if (descriptor !== undefined)
1072
- closeSync(descriptor);
1073
- try {
1074
- unlinkSync(temporaryPath);
1075
- }
1076
- catch (cleanupError) {
1077
- if (!isFsError(cleanupError, "ENOENT"))
1078
- throw cleanupError;
1079
- }
1080
- throw error;
1081
- }
1082
- }
1083
1187
  function isFsError(error, code) {
1084
1188
  return (typeof error === "object" &&
1085
1189
  error !== null &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-profile-materialize",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Materialize an AgentProfile into the native per-harness workspace files (the one shared materializer for cli-bridge, agent-dev-container, and VB).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",