arcane-os 0.3.1 → 0.3.2

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 (153) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +86 -117
  3. package/bin/arcane-test.mjs +170 -46
  4. package/browser-runtime/ai/browser-speech-artifacts.mjs +855 -895
  5. package/browser-runtime/ai/browser-speech-providers.mjs +80 -204
  6. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +627 -819
  7. package/browser-runtime/ai/browser-wasm.mjs +24 -35
  8. package/browser-runtime/ai/browser-wllama-runtime.mjs +64 -316
  9. package/browser-runtime/ai/model-controller.mjs +584 -181
  10. package/browser-runtime/ai/speech-worker-client.mjs +8 -148
  11. package/browser-runtime/ai/speech-worker-runtime.mjs +642 -374
  12. package/browser-runtime/dom-event-instrumentation.mjs +55 -147
  13. package/browser-runtime/event-manager.mjs +239 -624
  14. package/package.json +5 -6
  15. package/runtime/arcane/components/app-bar.html +3 -15
  16. package/runtime/arcane/components/assistant-panel.html +10 -10
  17. package/runtime/arcane/components/calculator.html +1 -1
  18. package/runtime/arcane/components/chat.html +1359 -135
  19. package/runtime/arcane/components/conversation-view.html +2 -2
  20. package/runtime/arcane/components/document-inspector.html +11 -17
  21. package/runtime/arcane/components/file-manager.html +13 -56
  22. package/runtime/arcane/components/markdown-document.html +82 -281
  23. package/runtime/arcane/components/markdown-editor.html +7 -10
  24. package/runtime/arcane/components/media-embed.html +6 -6
  25. package/runtime/arcane/components/screen-capture.html +4 -4
  26. package/runtime/arcane/components/source-explanation.html +2 -2
  27. package/runtime/arcane/components/speech.html +112 -68
  28. package/runtime/arcane/components/terminal-workspace.html +4 -4
  29. package/runtime/arcane/components/theme-editor.html +1 -1
  30. package/runtime/arcane/components/unified-inbox.html +2 -2
  31. package/runtime/arcane/components/voice-transcription.html +31 -21
  32. package/runtime/arcane/entities/Calculation.js +2 -3
  33. package/runtime/arcane/entities/Chat.js +228 -43
  34. package/runtime/arcane/entities/Preference.js +3 -5
  35. package/runtime/arcane/entities/Weather.js +5 -5
  36. package/runtime/arcane/modules/AI.js +1042 -427
  37. package/runtime/arcane/modules/AIProviderRuntime.js +618 -359
  38. package/runtime/arcane/modules/AIResponseLength.js +9 -19
  39. package/runtime/arcane/modules/AIRuntimeState.js +109 -72
  40. package/runtime/arcane/modules/ArcaneNavigationPolicy.js +45 -32
  41. package/runtime/arcane/modules/BrowserTestSuite.js +78 -122
  42. package/runtime/arcane/modules/CalculatorEngine.js +9 -9
  43. package/runtime/arcane/modules/CommunicationAppController.js +3 -7
  44. package/runtime/arcane/modules/ComponentContracts.js +30 -32
  45. package/runtime/arcane/modules/ConfiguredAIChatSession.js +281 -230
  46. package/runtime/arcane/modules/ConversationActionItems.js +26 -59
  47. package/runtime/arcane/modules/ConversationClosingReport.js +34 -61
  48. package/runtime/arcane/modules/ConversationTimebox.js +27 -15
  49. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +152 -344
  50. package/runtime/arcane/modules/DocumentLexicalSearch.js +25 -91
  51. package/runtime/arcane/modules/HTMLImport.js +54 -1
  52. package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +40 -203
  53. package/runtime/arcane/modules/LocalAIReadiness.js +40 -60
  54. package/runtime/arcane/modules/LocalAIReadinessController.js +15 -13
  55. package/runtime/arcane/modules/MD.js +1 -45
  56. package/runtime/arcane/modules/Mail.js +51 -103
  57. package/runtime/arcane/modules/MailOutbox.mjs +95 -193
  58. package/runtime/arcane/modules/MailTransport.mjs +36 -57
  59. package/runtime/arcane/modules/ModelDefinition.js +22 -106
  60. package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +39 -101
  61. package/runtime/arcane/modules/PersistentAIChatSession.js +281 -18
  62. package/runtime/arcane/modules/PreferenceStore.js +102 -30
  63. package/runtime/arcane/modules/RiskSignalAnalyzer.js +8 -9
  64. package/runtime/arcane/modules/ScopedOPFSCache.js +7 -42
  65. package/runtime/arcane/modules/ScreenCapture.js +175 -128
  66. package/runtime/arcane/modules/SpeechPlayback.js +46 -149
  67. package/runtime/arcane/modules/StaticDocumentCatalog.js +173 -407
  68. package/runtime/arcane/modules/ToolCallRouter.js +25 -12
  69. package/runtime/arcane/modules/YouTubeMedia.js +6 -5
  70. package/schemas/arcane-app-bundle.schema.json +13 -78
  71. package/schemas/arcane-app.schema.json +9 -25
  72. package/schemas/arcane-lock.schema.json +18 -151
  73. package/schemas/arcane-package.schema.json +2 -16
  74. package/schemas/native-build-plan.schema.json +119 -122
  75. package/src/app-descriptor.mjs +75 -132
  76. package/src/application-tests.mjs +200 -0
  77. package/src/cli/main.mjs +27 -46
  78. package/src/constants.mjs +3 -4
  79. package/src/dev-server.mjs +30 -324
  80. package/src/doctor.mjs +92 -154
  81. package/src/dom-event-instrumentation.mjs +55 -147
  82. package/src/errors.mjs +2 -3
  83. package/src/event-manager.mjs +239 -624
  84. package/src/event-queue.mjs +3 -3
  85. package/src/import-map.mjs +273 -1028
  86. package/src/index.mjs +14 -16
  87. package/src/installed-sdk-runtime.mjs +27 -67
  88. package/src/integrated-provider-loader.mjs +53 -382
  89. package/src/mail-api.mjs +0 -2
  90. package/src/mail-server.mjs +224 -580
  91. package/src/mail.mjs +4 -10
  92. package/src/native-plan.mjs +163 -598
  93. package/src/native-provider-loader.mjs +104 -1063
  94. package/src/packager/core.mjs +485 -3229
  95. package/src/process.mjs +5 -10
  96. package/src/release-bundle.mjs +292 -2405
  97. package/src/runtime.mjs +76 -396
  98. package/src/scaffold.mjs +30 -80
  99. package/src/sdk-browser-runtime.mjs +70 -626
  100. package/src/source-server.mjs +588 -0
  101. package/src/targets/index.mjs +78 -188
  102. package/src/templates/workspace-template.mjs +19 -135
  103. package/src/testing-loader.mjs +164 -0
  104. package/src/testing.mjs +1 -1
  105. package/src/toolchain.mjs +131 -544
  106. package/src/update-check.mjs +26 -64
  107. package/src/workspace-operation-lock.mjs +139 -430
  108. package/src/workspace-runtime.mjs +109 -1558
  109. package/src/workspace.mjs +40 -302
  110. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +0 -218
  111. package/browser-runtime/ai/ARCANE_AI_BROWSER_SPEECH_COMPONENTS.json +0 -203
  112. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +0 -80
  113. package/browser-runtime/ai/internal/sha256.mjs +0 -166
  114. package/docs/architecture.md +0 -344
  115. package/docs/compatibility.md +0 -36
  116. package/docs/event-manager.md +0 -294
  117. package/docs/platform-targets.md +0 -108
  118. package/docs/publishing.md +0 -201
  119. package/docs/reference/README.md +0 -185
  120. package/docs/reference/ai/browser-speech-package-authority.json +0 -835
  121. package/docs/reference/ai/browser-speech.md +0 -1295
  122. package/docs/reference/ai/browser-wasm.md +0 -530
  123. package/docs/reference/arcane-ollama.md +0 -288
  124. package/docs/reference/availability-and-normalization.md +0 -183
  125. package/docs/reference/behavioral-testing.md +0 -133
  126. package/docs/reference/cli.md +0 -779
  127. package/docs/reference/core/README.md +0 -62
  128. package/docs/reference/core/arcane-ai-contracts.md +0 -907
  129. package/docs/reference/core/arcane-api.md +0 -601
  130. package/docs/reference/core/arcane-entities.md +0 -65
  131. package/docs/reference/core/arcane-events.md +0 -134
  132. package/docs/reference/core/ollama-module.md +0 -181
  133. package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +0 -1909
  134. package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +0 -1057
  135. package/docs/reference/core/reference/arcane-api/core-and-events.md +0 -320
  136. package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +0 -610
  137. package/docs/reference/core/reference/arcane-api/namespaces.md +0 -1157
  138. package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +0 -1423
  139. package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +0 -315
  140. package/docs/reference/event-manager.md +0 -1511
  141. package/docs/reference/inventory/package-api.json +0 -3284
  142. package/docs/reference/inventory/runtime-components.json +0 -1011
  143. package/docs/reference/inventory/runtime-entities.json +0 -26
  144. package/docs/reference/inventory/runtime-modules.json +0 -1431
  145. package/docs/reference/mail.md +0 -316
  146. package/docs/reference/protocols.md +0 -719
  147. package/docs/reference/runtime-components.md +0 -1366
  148. package/docs/reference/runtime-entities.md +0 -303
  149. package/docs/reference/runtime-modules.md +0 -2965
  150. package/docs/reference/sdk-api.md +0 -6698
  151. package/docs/roadmap.md +0 -79
  152. package/docs/work-amplification.md +0 -129
  153. package/runtime/ARCANE_RUNTIME_RELEASE.json +0 -826
@@ -1,38 +1,18 @@
1
1
  import {randomUUID} from 'node:crypto';
2
2
  import {constants as FS_CONSTANTS} from 'node:fs';
3
- import {lstat,mkdir,open,realpath,rename,rm} from 'node:fs/promises';
3
+ import {lstat,mkdir,open,readFile,realpath,rm} from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
 
6
6
  const LOCK_DIRECTORY='.arcane';
7
7
  const LOCK_NAME='workspace-operation.lock.json';
8
8
  const LOCK_TTL_MILLISECONDS=6*60*60*1000;
9
- const MAX_LOCK_BYTES=64*1024;
10
9
  const OPEN_EXCLUSIVE=FS_CONSTANTS.O_CREAT|FS_CONSTANTS.O_EXCL|FS_CONSTANTS.O_RDWR
11
10
  |(FS_CONSTANTS.O_NOFOLLOW??0);
12
- const OPEN_READ=FS_CONSTANTS.O_RDONLY|(FS_CONSTANTS.O_NOFOLLOW??0);
13
11
  const RELEASE_PROCEDURE=
14
- 'The owning Arcane process removes this identity-checked lock after the operation settles.';
12
+ 'The owning Arcane process removes this cooperative lock after the operation settles.';
15
13
  const STALE_RECOVERY=
16
- 'Only after expiresAt, confirm the recorded owner process is absent, preserve workspace '
17
- +'changes, and remove this exact identity-checked lock. A live owner is never displaced.';
18
- const SECURITY_BOUNDARY=
19
- 'This lock coordinates cooperative Arcane SDK mutators. Privileged or non-cooperating '
20
- +'filesystem mutation is outside the portable JavaScript security boundary.';
21
- const DOCUMENT_KEYS=Object.freeze([
22
- 'acquiredAt',
23
- 'expiresAt',
24
- 'kind',
25
- 'nonce',
26
- 'operation',
27
- 'owner',
28
- 'releaseProcedure',
29
- 'schemaVersion',
30
- 'scope',
31
- 'securityBoundary',
32
- 'staleRecovery',
33
- 'ttlMilliseconds'
34
- ].sort());
35
- const OWNER_KEYS=Object.freeze(['pid']);
14
+ 'After expiresAt, confirm the recorded owner process is absent, preserve workspace changes, '
15
+ +'and remove only this exact cooperative lock.';
36
16
  const activeRoots=new Map();
37
17
  const leaseStates=new WeakMap();
38
18
 
@@ -48,24 +28,6 @@ function workspaceBusy(message){
48
28
  return error;
49
29
  }
50
30
 
51
- function trackedLeaseState(fields){
52
- let resolveSettled;
53
- const settled=new Promise(resolve=>{resolveSettled=resolve;});
54
- return {
55
- ...fields,
56
- releasePromise:null,
57
- settled,
58
- settledComplete:false,
59
- resolveSettled
60
- };
61
- }
62
-
63
- function settleLeaseState(state){
64
- if(state.settledComplete)return;
65
- state.settledComplete=true;
66
- state.resolveSettled();
67
- }
68
-
69
31
  function throwIfAborted(signal){
70
32
  if(!signal?.aborted)return;
71
33
  const error=signal.reason instanceof Error?signal.reason:new Error('Operation cancelled.');
@@ -73,16 +35,6 @@ function throwIfAborted(signal){
73
35
  throw error;
74
36
  }
75
37
 
76
- function sameLocation(left,right){
77
- return left.dev===right.dev&&left.ino===right.ino;
78
- }
79
-
80
- function sameFileState(left,right){
81
- return sameLocation(left,right)&&left.size===right.size
82
- &&left.mtimeNs===right.mtimeNs&&left.ctimeNs===right.ctimeNs
83
- &&left.nlink===right.nlink&&left.mode===right.mode;
84
- }
85
-
86
38
  function samePath(left,right){
87
39
  const normalize=value=>{
88
40
  const resolved=path.resolve(value);
@@ -91,118 +43,60 @@ function samePath(left,right){
91
43
  return normalize(left)===normalize(right);
92
44
  }
93
45
 
94
- function exactKeys(value,expected){
95
- return value!==null&&typeof value==='object'&&!Array.isArray(value)
96
- &&JSON.stringify(Object.keys(value).sort())===JSON.stringify(expected);
46
+ function validOperation(value){
47
+ return typeof value==='string'&&/^[a-z][a-z0-9._-]*$/u.test(value);
97
48
  }
98
49
 
99
- async function captureRealDirectoryChain(directory,label){
100
- const resolved=path.resolve(directory);
101
- let requestedIdentity;
102
- try{requestedIdentity=await lstat(resolved,{bigint:true});}
103
- catch(error){
104
- if(error?.code==='ENOENT'){
105
- fail(`${label} does not exist.`, 'ARCANE_POLICY_DENIED');
106
- }
107
- throw error;
108
- }
109
- if(requestedIdentity.isSymbolicLink()||!requestedIdentity.isDirectory()){
110
- fail(`${label} must be a physical directory, not a symbolic link or junction.`,
111
- 'ARCANE_POLICY_DENIED');
112
- }
113
- const canonical=await realpath(resolved);
114
- const root=path.parse(canonical).root;
115
- const relative=path.relative(root,canonical);
116
- const segments=relative===''?[]:relative.split(path.sep).filter(Boolean);
117
- const records=[];
118
- let current=root;
119
- for(const segment of [null,...segments]){
120
- if(segment!==null)current=path.join(current,segment);
121
- let info;
122
- try{info=await lstat(current,{bigint:true});}
123
- catch(error){
124
- if(error?.code==='ENOENT'){
125
- fail(`${label} does not exist.`, 'ARCANE_POLICY_DENIED');
126
- }
127
- throw error;
128
- }
129
- if(info.isSymbolicLink()||!info.isDirectory()){
130
- fail(`${label} must not contain a symbolic link, junction, or non-directory ancestor.`,
131
- 'ARCANE_POLICY_DENIED');
132
- }
133
- records.push(Object.freeze({path:current,identity:info}));
134
- }
135
- const identity=records.at(-1).identity;
136
- if(!sameLocation(requestedIdentity,identity)){
137
- fail(`${label} must resolve to its captured directory identity.`,
138
- 'ARCANE_POLICY_DENIED');
139
- }
140
- return Object.freeze({
141
- requested:resolved,
142
- requestedIdentity,
143
- canonical,
144
- identity,
145
- records:Object.freeze(records)
146
- });
50
+ function trackedLeaseState(fields){
51
+ let resolveSettled;
52
+ const settled=new Promise(resolve=>{resolveSettled=resolve;});
53
+ return {
54
+ ...fields,
55
+ releasePromise:null,
56
+ settled,
57
+ settledComplete:false,
58
+ resolveSettled
59
+ };
147
60
  }
148
61
 
149
- async function assertDirectoryChain(chain,label){
150
- for(const record of chain.records){
151
- let current;
152
- try{current=await lstat(record.path,{bigint:true});}
153
- catch(error){
154
- if(error?.code==='ENOENT'){
155
- fail(`${label} changed while its operation boundary was active.`,
156
- 'ARCANE_POLICY_DENIED');
157
- }
158
- throw error;
159
- }
160
- if(current.isSymbolicLink()||!current.isDirectory()
161
- ||!sameLocation(current,record.identity)){
162
- fail(`${label} changed while its operation boundary was active.`,
163
- 'ARCANE_POLICY_DENIED');
164
- }
62
+ function settleLeaseState(state){
63
+ if(state.settledComplete)return;
64
+ state.settledComplete=true;
65
+ state.resolveSettled();
66
+ }
67
+
68
+ async function physicalDirectory(directory,label,{create=false}={}){
69
+ const requested=path.resolve(directory);
70
+ if(create){
71
+ await mkdir(requested,{recursive:true});
165
72
  }
166
- let canonical;
167
- let requestedIdentity;
73
+ let information;
168
74
  try{
169
- canonical=await realpath(chain.requested);
170
- requestedIdentity=await lstat(chain.requested,{bigint:true});
75
+ information=await lstat(requested);
171
76
  }catch(error){
172
77
  if(error?.code==='ENOENT'){
173
- fail(`${label} changed while its operation boundary was active.`,
174
- 'ARCANE_POLICY_DENIED');
78
+ fail(`${label} does not exist.`,'ARCANE_POLICY_DENIED');
175
79
  }
176
80
  throw error;
177
81
  }
178
- if(!samePath(canonical,chain.canonical)
179
- ||requestedIdentity.isSymbolicLink()||!requestedIdentity.isDirectory()
180
- ||!sameLocation(requestedIdentity,chain.requestedIdentity)
181
- ||!sameLocation(requestedIdentity,chain.identity)){
182
- fail(`${label} changed while its operation boundary was active.`,
183
- 'ARCANE_POLICY_DENIED');
82
+ if(information.isSymbolicLink()||!information.isDirectory()){
83
+ fail(`${label} must be a physical directory.`,'ARCANE_POLICY_DENIED');
84
+ }
85
+ const canonical=await realpath(requested);
86
+ const canonicalInformation=await lstat(canonical);
87
+ if(canonicalInformation.isSymbolicLink()||!canonicalInformation.isDirectory()){
88
+ fail(`${label} must resolve to a physical directory.`,'ARCANE_POLICY_DENIED');
184
89
  }
90
+ return {requested,canonical};
185
91
  }
186
92
 
187
93
  async function ensureLockDirectory(workspace){
188
- await assertDirectoryChain(workspace,'Arcane workspace');
189
- const requested=path.join(workspace.canonical,LOCK_DIRECTORY);
190
- try{await mkdir(requested,{mode:0o700});}
191
- catch(error){if(error?.code!=='EEXIST')throw error;}
192
- const directory=await captureRealDirectoryChain(
193
- requested,
194
- 'Workspace operation-lock directory'
195
- );
196
- if(!samePath(path.dirname(directory.canonical),workspace.canonical)){
197
- fail('Workspace operation-lock directory must remain directly inside the workspace.',
198
- 'ARCANE_POLICY_DENIED');
199
- }
200
- await assertDirectoryChain(workspace,'Arcane workspace');
201
- return directory;
94
+ const directory=path.join(workspace.canonical,LOCK_DIRECTORY);
95
+ return physicalDirectory(directory,'Workspace operation-lock directory',{create:true});
202
96
  }
203
97
 
204
98
  function ownerIsAlive(pid){
205
- if(!Number.isSafeInteger(pid)||pid<=0)return true;
99
+ if(!Number.isSafeInteger(pid)||pid<1)return false;
206
100
  try{
207
101
  process.kill(pid,0);
208
102
  return true;
@@ -211,217 +105,73 @@ function ownerIsAlive(pid){
211
105
  }
212
106
  }
213
107
 
214
- function validOperation(operation){
215
- return typeof operation==='string'&&/^[a-z0-9][a-z0-9.-]*$/u.test(operation);
216
- }
217
-
218
- function recoverableDocument(document,{workspaceRoot,now}){
219
- if(!exactKeys(document,DOCUMENT_KEYS)||!exactKeys(document.owner,OWNER_KEYS))return false;
220
- const acquired=Date.parse(document.acquiredAt);
221
- const expires=Date.parse(document.expiresAt);
222
- return document.schemaVersion===1
223
- &&document.kind==='arcane-workspace-operation-lock'
224
- &&validOperation(document.operation)
225
- &&Number.isSafeInteger(document.owner.pid)
226
- &&document.owner.pid>0
227
- &&typeof document.scope==='string'
228
- &&samePath(document.scope,workspaceRoot)
229
- &&typeof document.nonce==='string'
230
- &&/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
231
- .test(document.nonce)
232
- &&Number.isFinite(acquired)
233
- &&Number.isFinite(expires)
234
- &&document.ttlMilliseconds===LOCK_TTL_MILLISECONDS
235
- &&expires===acquired+LOCK_TTL_MILLISECONDS
236
- &&expires<=now
237
- &&document.releaseProcedure===RELEASE_PROCEDURE
238
- &&document.staleRecovery===STALE_RECOVERY
239
- &&document.securityBoundary===SECURITY_BOUNDARY
240
- &&!ownerIsAlive(document.owner.pid);
241
- }
242
-
243
- async function existingRegularLock(lockPath){
244
- let current;
245
- try{current=await lstat(lockPath,{bigint:true});}
246
- catch(error){
247
- if(error?.code==='ENOENT')return null;
248
- throw error;
108
+ function lockDocument(value,{workspaceRoot,now=Date.now()}={}){
109
+ if(!value||typeof value!=='object'||Array.isArray(value)
110
+ ||value.schemaVersion!==1
111
+ ||value.kind!=='arcane-workspace-operation-lock'
112
+ ||!validOperation(value.operation)
113
+ ||typeof value.nonce!=='string'||!value.nonce
114
+ ||!value.owner||!Number.isSafeInteger(value.owner.pid)
115
+ ||typeof value.scope!=='string'||!samePath(value.scope,workspaceRoot)
116
+ ||typeof value.acquiredAt!=='string'
117
+ ||typeof value.expiresAt!=='string'
118
+ ||value.releaseProcedure!==RELEASE_PROCEDURE
119
+ ||value.staleRecovery!==STALE_RECOVERY){
120
+ return null;
249
121
  }
250
- if(current.isSymbolicLink()||!current.isFile()){
251
- fail('Workspace operation lock path is a symbolic link, junction, or non-file.',
252
- 'ARCANE_POLICY_DENIED');
253
- }
254
- return current;
255
- }
256
-
257
- async function restoreQuarantinedLock({directory,lockPath,quarantinePath,identity}){
258
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
259
- const quarantined=await existingRegularLock(quarantinePath);
260
- if(quarantined===null||!sameLocation(quarantined,identity))return false;
261
- if(await existingRegularLock(lockPath)!==null)return false;
262
- await rename(quarantinePath,lockPath);
263
- const restored=await existingRegularLock(lockPath);
264
- return restored!==null&&sameLocation(restored,identity);
122
+ const expiresAt=Date.parse(value.expiresAt);
123
+ if(!Number.isFinite(expiresAt))return null;
124
+ return {...value,expired:expiresAt<=now};
265
125
  }
266
126
 
267
- async function recoverExpiredLock(lockPath,directory,workspaceRoot,onEvent){
268
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
269
- const initial=await existingRegularLock(lockPath);
270
- if(initial===null)return true;
271
- let handle;
272
- try{handle=await open(lockPath,OPEN_READ);}
273
- catch(error){
274
- if(error?.code==='ENOENT')return true;
275
- if(error?.code==='ELOOP'){
276
- fail('Workspace operation lock path is a symbolic link or junction.',
277
- 'ARCANE_POLICY_DENIED');
278
- }
127
+ async function readLock(lockPath,workspaceRoot){
128
+ let information;
129
+ try{
130
+ information=await lstat(lockPath);
131
+ }catch(error){
132
+ if(error?.code==='ENOENT')return null;
279
133
  throw error;
280
134
  }
135
+ if(information.isSymbolicLink()||!information.isFile())return null;
136
+ let value;
281
137
  try{
282
- const before=await handle.stat({bigint:true});
283
- if(!before.isFile()||before.size>BigInt(MAX_LOCK_BYTES)
284
- ||!sameFileState(before,initial))return false;
285
- const bytes=await handle.readFile();
286
- const after=await handle.stat({bigint:true});
287
- const current=await existingRegularLock(lockPath);
288
- if(current===null)return true;
289
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
290
- if(!sameFileState(before,after)||!sameFileState(after,current))return false;
291
- let document;
292
- try{document=JSON.parse(bytes.toString('utf8'));}
293
- catch{return false;}
294
- if(!recoverableDocument(document,{workspaceRoot,now:Date.now()}))return false;
295
- const canonical=Buffer.from(`${JSON.stringify(document,null,2)}\n`,'utf8');
296
- if(!canonical.equals(bytes))return false;
297
- await handle.close();
298
- handle=null;
299
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
300
- const final=await existingRegularLock(lockPath);
301
- if(final===null)return true;
302
- if(!sameFileState(final,current))return false;
303
-
304
- const quarantinePath=path.join(
305
- directory.canonical,
306
- `.${LOCK_NAME}.arcane-stale-${String(process.pid)}-${randomUUID()}`
307
- );
308
- await rename(lockPath,quarantinePath);
309
- let quarantinedHandle;
310
- let quarantineIdentity=await existingRegularLock(quarantinePath);
311
- if(quarantineIdentity===null||!sameLocation(quarantineIdentity,final)){
312
- return false;
313
- }
314
- let valid=false;
315
- try{
316
- await onEvent?.(Object.freeze({
317
- type:'workspace.operation.stale-quarantined',
318
- workspaceRoot,
319
- quarantinePath
320
- }));
321
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
322
- const quarantined=await existingRegularLock(quarantinePath);
323
- if(quarantined===null||!sameFileState(quarantined,quarantineIdentity))return false;
324
- quarantinedHandle=await open(quarantinePath,OPEN_READ);
325
- const quarantineBefore=await quarantinedHandle.stat({bigint:true});
326
- if(!sameFileState(quarantineBefore,quarantineIdentity))return false;
327
- const quarantineBytes=await quarantinedHandle.readFile();
328
- const quarantineAfter=await quarantinedHandle.stat({bigint:true});
329
- const quarantineCurrent=await existingRegularLock(quarantinePath);
330
- if(quarantineCurrent===null
331
- ||!sameFileState(quarantineBefore,quarantineAfter)
332
- ||!sameFileState(quarantineAfter,quarantineCurrent)
333
- ||!quarantineBytes.equals(bytes))return false;
334
- let quarantineDocument;
335
- try{quarantineDocument=JSON.parse(quarantineBytes.toString('utf8'));}
336
- catch{return false;}
337
- const quarantineCanonical=Buffer.from(
338
- `${JSON.stringify(quarantineDocument,null,2)}\n`,
339
- 'utf8'
340
- );
341
- if(!quarantineCanonical.equals(quarantineBytes)
342
- ||!recoverableDocument(quarantineDocument,{workspaceRoot,now:Date.now()})){
343
- return false;
344
- }
345
- valid=true;
346
- }finally{
347
- await quarantinedHandle?.close().catch(()=>{});
348
- if(!valid){
349
- await restoreQuarantinedLock({
350
- directory,lockPath,quarantinePath,identity:quarantineIdentity
351
- }).catch(()=>{});
352
- }
353
- }
354
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
355
- const deletionCandidate=await existingRegularLock(quarantinePath);
356
- if(deletionCandidate===null||!sameFileState(deletionCandidate,quarantineIdentity)){
357
- await restoreQuarantinedLock({
358
- directory,lockPath,quarantinePath,identity:quarantineIdentity
359
- }).catch(()=>{});
360
- return false;
361
- }
362
- await rm(quarantinePath);
363
- return true;
364
- }finally{
365
- await handle?.close().catch(()=>{});
138
+ value=JSON.parse(await readFile(lockPath,'utf8'));
139
+ }catch{
140
+ return null;
366
141
  }
142
+ return lockDocument(value,{workspaceRoot});
367
143
  }
368
144
 
369
- async function validateOwnedLock({workspace,directory,lockPath,handle,identity,bytes,label}){
370
- await assertDirectoryChain(workspace,'Arcane workspace');
371
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
372
- let opened;
373
- let current;
374
- try{
375
- opened=await handle.stat({bigint:true});
376
- current=await lstat(lockPath,{bigint:true});
377
- }catch(error){
378
- if(error?.code==='ENOENT'){
379
- fail(`Workspace operation lock disappeared ${label}.`, 'ARCANE_POLICY_DENIED');
380
- }
381
- throw error;
382
- }
383
- if(!opened.isFile()||opened.size!==BigInt(bytes.length)
384
- ||!sameLocation(opened,identity)||current.isSymbolicLink()||!current.isFile()
385
- ||!sameLocation(current,identity)){
386
- fail(`Workspace operation lock changed ${label}.`, 'ARCANE_POLICY_DENIED');
387
- }
388
- const read=Buffer.alloc(bytes.length);
389
- const result=await handle.read(read,0,read.length,0);
390
- const after=await handle.stat({bigint:true});
391
- if(result.bytesRead!==bytes.length||!read.equals(bytes)
392
- ||!sameLocation(after,identity)||after.size!==BigInt(bytes.length)){
393
- fail(`Workspace operation lock contents changed ${label}.`, 'ARCANE_POLICY_DENIED');
394
- }
145
+ async function recoverExpiredLock(lockPath,workspaceRoot,onEvent){
146
+ const document=await readLock(lockPath,workspaceRoot);
147
+ if(!document?.expired||ownerIsAlive(document.owner.pid))return false;
148
+ await onEvent?.({
149
+ type:'workspace.operation.stale-recovered',
150
+ workspaceRoot,
151
+ lockPath,
152
+ previousOwner:document.owner,
153
+ previousOperation:document.operation
154
+ });
155
+ await rm(lockPath);
156
+ return true;
395
157
  }
396
158
 
397
- async function removeOwnedLock({directory,lockPath,handle,identity}){
398
- if(!handle||!identity)return {handle:null,removed:false};
399
- let owned=false;
400
- try{
401
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
402
- const opened=await handle.stat({bigint:true});
403
- const current=await existingRegularLock(lockPath);
404
- owned=current!==null&&sameLocation(opened,identity)&&sameLocation(current,identity);
405
- }finally{
406
- await handle.close().catch(()=>{});
407
- handle=null;
408
- }
409
- if(!owned)return {handle,removed:false};
410
- await assertDirectoryChain(directory,'Workspace operation-lock directory');
411
- const final=await existingRegularLock(lockPath);
412
- if(final===null)return {handle,removed:true};
413
- if(!sameLocation(final,identity))return {handle,removed:false};
159
+ async function removeOwnedLock({lockPath,workspaceRoot,nonce,handle}){
160
+ await handle?.close().catch(()=>{});
161
+ const document=await readLock(lockPath,workspaceRoot);
162
+ if(document===null)return true;
163
+ if(document.nonce!==nonce||document.owner.pid!==process.pid)return false;
414
164
  await rm(lockPath);
415
- return {handle,removed:true};
165
+ return true;
416
166
  }
417
167
 
418
168
  function leaseFor(state,operation){
419
- const lease=Object.freeze({
169
+ const lease={
420
170
  workspaceRoot:state.root.workspace.canonical,
421
171
  operation,
422
172
  nonce:randomUUID(),
423
173
  lockPath:state.root.lockPath
424
- });
174
+ };
425
175
  leaseStates.set(lease,state);
426
176
  return lease;
427
177
  }
@@ -430,10 +180,12 @@ function inheritedLease(workspace,operation,supplied){
430
180
  const parent=leaseStates.get(supplied);
431
181
  const root=parent?.root;
432
182
  if(!parent?.active||!root?.active
433
- ||!samePath(parent.root.workspace.canonical,workspace.canonical)
183
+ ||!samePath(root.workspace.canonical,workspace.canonical)
434
184
  ||activeRoots.get(workspace.canonical)!==root.lease){
435
- fail('Arcane workspace operation lease is missing, inactive, or belongs to another workspace.',
436
- 'ARCANE_POLICY_DENIED');
185
+ fail(
186
+ 'Arcane workspace operation lease is missing, inactive, or belongs to another workspace.',
187
+ 'ARCANE_POLICY_DENIED'
188
+ );
437
189
  }
438
190
  if(parent.child!==null){
439
191
  fail('The supplied Arcane workspace operation lease already has active nested work.');
@@ -481,22 +233,21 @@ async function acquire({
481
233
  if(!validOperation(operation)){
482
234
  throw new TypeError('operation must be a stable lowercase Arcane operation name.');
483
235
  }
484
- const workspace=await captureRealDirectoryChain(path.resolve(workspaceRoot),'Arcane workspace');
236
+ const workspace=await physicalDirectory(workspaceRoot,'Arcane workspace');
485
237
  if(workspaceOperationLease!==undefined){
486
238
  return inheritedLease(workspace,operation,workspaceOperationLease);
487
239
  }
488
240
  if(activeRoots.has(workspace.canonical)){
489
241
  fail(`Another Arcane operation already owns ${workspace.canonical}.`);
490
242
  }
491
- const reservation=Object.freeze({kind:'arcane-workspace-operation-reservation'});
243
+ const reservation={kind:'arcane-workspace-operation-reservation'};
492
244
  activeRoots.set(workspace.canonical,reservation);
493
- let directory;
494
- let lockPath;
495
245
  let handle;
496
- let identity;
246
+ let lockPath;
497
247
  let rootState;
248
+ let nonce;
498
249
  try{
499
- directory=await ensureLockDirectory(workspace);
250
+ const directory=await ensureLockDirectory(workspace);
500
251
  lockPath=path.join(directory.canonical,LOCK_NAME);
501
252
  let recovered=false;
502
253
  for(;;){
@@ -504,93 +255,56 @@ async function acquire({
504
255
  handle=await open(lockPath,OPEN_EXCLUSIVE,0o600);
505
256
  break;
506
257
  }catch(error){
507
- if(error?.code==='EEXIST'){
508
- if(recovered){
509
- fail(
510
- `Workspace is locked by another Arcane operation: ${lockPath}. `
511
- +'Inspect its owner, expiry, and staleRecovery before retrying.'
512
- );
513
- }
514
- const didRecover=await recoverExpiredLock(
515
- lockPath,
516
- directory,
517
- workspace.canonical,
518
- onEvent
258
+ if(error?.code!=='EEXIST')throw error;
259
+ if(recovered||!(await recoverExpiredLock(
260
+ lockPath,
261
+ workspace.canonical,
262
+ onEvent
263
+ ))){
264
+ fail(
265
+ `Workspace is locked by another Arcane operation: ${lockPath}. `
266
+ +'Inspect its owner, expiry, and staleRecovery before retrying.'
519
267
  );
520
- if(!didRecover){
521
- fail(
522
- `Workspace is locked by another Arcane operation: ${lockPath}. `
523
- +'Inspect its owner, expiry, and staleRecovery before retrying.'
524
- );
525
- }
526
- recovered=true;
527
- continue;
528
- }
529
- if(error?.code==='ELOOP'){
530
- fail('Workspace operation lock path is a symbolic link or junction.',
531
- 'ARCANE_POLICY_DENIED');
532
268
  }
533
- throw error;
269
+ recovered=true;
534
270
  }
535
271
  }
536
- // Claim cleanup ownership immediately after O_EXCL succeeds. This identity remains
537
- // usable even if a later write, sync, callback, or cancellation boundary fails.
538
- identity=await handle.stat({bigint:true});
539
- if(!identity.isFile()){
540
- fail('Workspace operation lock must be a regular file.','ARCANE_POLICY_DENIED');
541
- }
542
- const nonce=randomUUID();
272
+ nonce=randomUUID();
543
273
  const acquiredAt=new Date();
544
- const document=Object.freeze({
274
+ const document={
545
275
  schemaVersion:1,
546
276
  kind:'arcane-workspace-operation-lock',
547
277
  operation,
548
- owner:Object.freeze({pid:process.pid}),
278
+ owner:{pid:process.pid},
549
279
  scope:workspace.canonical,
550
280
  nonce,
551
281
  acquiredAt:acquiredAt.toISOString(),
552
282
  expiresAt:new Date(acquiredAt.getTime()+LOCK_TTL_MILLISECONDS).toISOString(),
553
283
  ttlMilliseconds:LOCK_TTL_MILLISECONDS,
554
284
  releaseProcedure:RELEASE_PROCEDURE,
555
- staleRecovery:STALE_RECOVERY,
556
- securityBoundary:SECURITY_BOUNDARY
557
- });
558
- const bytes=Buffer.from(`${JSON.stringify(document,null,2)}\n`,'utf8');
559
- await handle.writeFile(bytes);
285
+ staleRecovery:STALE_RECOVERY
286
+ };
287
+ await handle.writeFile(`${JSON.stringify(document,null,2)}\n`,'utf8');
560
288
  await handle.sync();
561
- await validateOwnedLock({
562
- workspace,directory,lockPath,handle,identity,bytes,label:'while it was acquired'
563
- });
564
289
  rootState=trackedLeaseState({
565
290
  active:true,
566
291
  child:null,
567
292
  workspace,
568
- directory,
569
293
  lockPath,
570
294
  handle,
571
- identity,
572
- bytes
295
+ nonce
573
296
  });
574
297
  rootState.root=rootState;
575
- const lease=Object.freeze({
576
- workspaceRoot:workspace.canonical,
577
- operation,
578
- nonce,
579
- lockPath
580
- });
298
+ const lease={workspaceRoot:workspace.canonical,operation,nonce,lockPath};
581
299
  rootState.lease=lease;
582
300
  leaseStates.set(lease,rootState);
583
301
  activeRoots.set(workspace.canonical,lease);
584
- await onEvent?.(Object.freeze({
302
+ await onEvent?.({
585
303
  type:'workspace.operation.locked',
586
304
  workspaceRoot:workspace.canonical,
587
305
  operation
588
- }));
589
- throwIfAborted(signal);
590
- await validateOwnedLock({
591
- workspace,directory,lockPath,handle,identity,bytes,
592
- label:'after the acquisition callback'
593
306
  });
307
+ throwIfAborted(signal);
594
308
  return {
595
309
  inherited:false,
596
310
  lease,
@@ -606,31 +320,20 @@ async function acquire({
606
320
  let observerError;
607
321
  if(child!==null)await child.settled;
608
322
  try{
609
- await validateOwnedLock({
610
- workspace,directory,lockPath,handle,identity,bytes,
611
- label:'before release'
323
+ const removed=await removeOwnedLock({
324
+ lockPath,
325
+ workspaceRoot:workspace.canonical,
326
+ nonce,
327
+ handle
612
328
  });
613
- const removed=await removeOwnedLock({directory,lockPath,handle,identity});
614
- handle=removed.handle;
615
- rootState.handle=handle;
616
- if(!removed.removed){
617
- fail('Workspace operation lock path changed before cleanup.',
618
- 'ARCANE_POLICY_DENIED');
329
+ handle=null;
330
+ rootState.handle=null;
331
+ if(!removed){
332
+ fail('Workspace operation lock belongs to another owner at cleanup.');
619
333
  }
620
334
  }catch(error){
621
335
  releaseError=error;
622
336
  if(misuseError)releaseError.nestedLeaseError=misuseError;
623
- if(handle){
624
- try{
625
- const removed=await removeOwnedLock({
626
- directory,lockPath,handle,identity
627
- });
628
- handle=removed.handle;
629
- rootState.handle=handle;
630
- }catch(cleanupError){
631
- releaseError.cleanupError=cleanupError;
632
- }
633
- }
634
337
  }finally{
635
338
  if(activeRoots.get(workspace.canonical)===lease){
636
339
  activeRoots.delete(workspace.canonical);
@@ -643,11 +346,11 @@ async function acquire({
643
346
  }
644
347
  if(releaseError)throw releaseError;
645
348
  try{
646
- await onEvent?.(Object.freeze({
349
+ await onEvent?.({
647
350
  type:'workspace.operation.released',
648
351
  workspaceRoot:workspace.canonical,
649
352
  operation
650
- }));
353
+ });
651
354
  }catch(error){observerError=error;}
652
355
  if(misuseError){
653
356
  if(observerError)misuseError.observerError=observerError;
@@ -668,15 +371,21 @@ async function acquire({
668
371
  ||activeRoots.get(workspace.canonical)===rootState?.lease){
669
372
  activeRoots.delete(workspace.canonical);
670
373
  }
671
- if(handle&&directory&&lockPath&&identity){
374
+ if(handle&&lockPath&&nonce){
672
375
  try{
673
- const removed=await removeOwnedLock({directory,lockPath,handle,identity});
674
- handle=removed.handle;
376
+ await removeOwnedLock({
377
+ lockPath,
378
+ workspaceRoot:workspace.canonical,
379
+ nonce,
380
+ handle
381
+ });
382
+ handle=null;
675
383
  }catch(cleanupError){
676
384
  error.cleanupError=cleanupError;
677
385
  }
386
+ }else{
387
+ await handle?.close().catch(()=>{});
678
388
  }
679
- await handle?.close().catch(()=>{});
680
389
  throw error;
681
390
  }
682
391
  }