@north-light/api 0.3.441 → 0.3.442

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.
@@ -27,6 +27,13 @@ export declare function syncDirectory(path: string): void;
27
27
  * tmux inbox watcher, or crtrd serving a ticket read — sees either the old
28
28
  * bytes or the new ones and never a truncated file mid-write. */
29
29
  export declare function atomicWriteText(path: string, content: string | Uint8Array, opts?: AtomicWriteOptions): void;
30
+ /** Publish `path` with `content` so it is never observable empty or partial, and
31
+ * never replaces an existing file. The bytes reach stable storage under a
32
+ * temporary name first; a hard link then makes them appear at `path` in one
33
+ * step and fails with EEXIST if another initializer won. The containing
34
+ * directory is fsynced so the new entry survives a volume handoff or power loss.
35
+ * Returns false when `path` already existed. */
36
+ export declare function publishFileExclusive(path: string, content: string, mode: number): boolean;
30
37
  /** Fsync every file and directory under `root` (and `root` itself), so a tree about to be renamed into place
31
38
  * reaches stable storage before the rename can. */
32
39
  export declare function syncTree(root: string): void;
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, closeSync, fsyncSync, openSync, } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, closeSync, fsyncSync, openSync, linkSync, unlinkSync, writeSync, } from 'node:fs';
2
2
  import { dirname, join, relative, sep } from 'node:path';
3
3
  import { homedir, platform } from 'node:os';
4
4
  /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
@@ -80,6 +80,45 @@ export function atomicWriteText(path, content, opts) {
80
80
  rmSync(temp, { force: true });
81
81
  }
82
82
  }
83
+ /** Publish `path` with `content` so it is never observable empty or partial, and
84
+ * never replaces an existing file. The bytes reach stable storage under a
85
+ * temporary name first; a hard link then makes them appear at `path` in one
86
+ * step and fails with EEXIST if another initializer won. The containing
87
+ * directory is fsynced so the new entry survives a volume handoff or power loss.
88
+ * Returns false when `path` already existed. */
89
+ export function publishFileExclusive(path, content, mode) {
90
+ const temporary = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
91
+ const fd = openSync(temporary, 'wx', mode);
92
+ try {
93
+ try {
94
+ const bytes = Buffer.from(content, 'utf8');
95
+ for (let offset = 0; offset < bytes.length;)
96
+ offset += writeSync(fd, bytes, offset, bytes.length - offset);
97
+ fsyncSync(fd);
98
+ }
99
+ finally {
100
+ closeSync(fd);
101
+ }
102
+ try {
103
+ linkSync(temporary, path);
104
+ }
105
+ catch (error) {
106
+ // No in-place fallback for a filesystem without hard links: creating the final name before its
107
+ // bytes are durable is exactly the empty marker this exists to prevent, so that fails loudly.
108
+ if (error.code === 'EEXIST')
109
+ return false;
110
+ throw error;
111
+ }
112
+ syncDirectory(dirname(path));
113
+ return true;
114
+ }
115
+ finally {
116
+ try {
117
+ unlinkSync(temporary);
118
+ }
119
+ catch { /* already gone */ }
120
+ }
121
+ }
83
122
  /** Fsync every file and directory under `root` (and `root` itself), so a tree about to be renamed into place
84
123
  * reaches stable storage before the rename can. */
85
124
  export function syncTree(root) {
@@ -31,6 +31,14 @@ export declare function cronFolder(app: string, cronId: string): string;
31
31
  * and the terminal wherever a sandbox exists, keeps its own app home. Callers
32
32
  * keep `PI_CODING_AGENT_DIR` in the app home either way. */
33
33
  export declare function appProcessHome(app: string): string;
34
+ /** The marker's byte length and an escaped, truncated preview, safe to print: a
35
+ * corrupt marker (empty, truncated, NUL-filled by a lost write) is otherwise
36
+ * indistinguishable from an intentional newer layout in a crash log. */
37
+ export declare function describeLayoutMarker(bytes: Uint8Array): string;
38
+ /** Refuse any marker other than the supported version. A non-matching marker is
39
+ * never rewritten: an empty or damaged one means the volume lost a write, and
40
+ * stamping the current version over it would assert a layout nobody verified. */
41
+ export declare function assertSupportedLayoutMarker(path: string): void;
34
42
  /** A missing version on an occupied volume is never initialized by daemon startup. */
35
43
  export declare function layoutNeedsMigration(): boolean;
36
44
  export declare function initializeEmptyLayout(): void;
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { homedir, userInfo } from 'node:os';
3
3
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { envHomeOverride } from '../shared/env.js';
5
5
  import { readConfig } from './config.js';
6
+ import { publishFileExclusive, syncDirectory } from './fs-utils.js';
6
7
  export const LAYOUT_VERSION = '1';
7
8
  /** CRTR_HOME names the volume root, not the canvas records directory. */
8
9
  export function layoutRoot() {
@@ -81,13 +82,29 @@ export function appProcessHome(app) {
81
82
  return space.home;
82
83
  return basename(space.root) === 'terminal' ? userFilesRoot() : space.home;
83
84
  }
85
+ /** The marker's byte length and an escaped, truncated preview, safe to print: a
86
+ * corrupt marker (empty, truncated, NUL-filled by a lost write) is otherwise
87
+ * indistinguishable from an intentional newer layout in a crash log. */
88
+ export function describeLayoutMarker(bytes) {
89
+ const preview = Array.from(bytes.subarray(0, 32), (byte) => byte === 0x5c ? '\\\\' : byte === 0x22 ? '\\"' : byte === 0x0a ? '\\n' : byte === 0x0d ? '\\r' : byte === 0x09 ? '\\t'
90
+ : byte >= 0x20 && byte < 0x7f ? String.fromCharCode(byte) : `\\x${byte.toString(16).padStart(2, '0')}`).join('');
91
+ return `${bytes.length} bytes, preview "${preview}"${bytes.length > 32 ? '…' : ''}`;
92
+ }
93
+ /** Refuse any marker other than the supported version. A non-matching marker is
94
+ * never rewritten: an empty or damaged one means the volume lost a write, and
95
+ * stamping the current version over it would assert a layout nobody verified. */
96
+ export function assertSupportedLayoutMarker(path) {
97
+ const bytes = readFileSync(path);
98
+ if (bytes.toString('utf8').trim() !== LAYOUT_VERSION) {
99
+ throw new Error(`unsupported runtime layout version: ${path} holds ${describeLayoutMarker(bytes)}; expected "${LAYOUT_VERSION}"`);
100
+ }
101
+ }
84
102
  /** A missing version on an occupied volume is never initialized by daemon startup. */
85
103
  export function layoutNeedsMigration() {
86
104
  if (process.env['CRTR_LAYOUT_REFUSE'] === '1')
87
105
  return true;
88
106
  if (existsSync(layoutVersionFile())) {
89
- if (readFileSync(layoutVersionFile(), 'utf8').trim() !== LAYOUT_VERSION)
90
- throw new Error('unsupported runtime layout version');
107
+ assertSupportedLayoutMarker(layoutVersionFile());
91
108
  return false;
92
109
  }
93
110
  const root = layoutRoot();
@@ -102,11 +119,22 @@ export function layoutNeedsMigration() {
102
119
  export function initializeEmptyLayout() {
103
120
  if (layoutNeedsMigration())
104
121
  throw new Error('layout_migration_required');
105
- for (const dir of [runtimeRoot(), userRoot(), appsRoot(), secretsDir()])
106
- mkdirSync(dir, { recursive: true, mode: 0o700 });
122
+ for (const dir of [runtimeRoot(), userRoot(), appsRoot(), secretsDir()]) {
123
+ // mkdir returns the first directory it had to create; every entry from there down to
124
+ // `dir` is new, and so is the entry for the first one in its (existing) parent.
125
+ const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
126
+ if (created === undefined)
127
+ continue;
128
+ for (let entry = dir;; entry = dirname(entry)) {
129
+ syncDirectory(entry);
130
+ if (entry === created)
131
+ break;
132
+ }
133
+ syncDirectory(dirname(created));
134
+ }
107
135
  mkdirSync(socketDir(), { recursive: true, mode: 0o755 });
108
136
  if (process.platform === 'linux' && (statSync(socketDir()).mode & 0o777) !== 0o755)
109
137
  throw new Error('launcher socket directory must be mode 0755');
110
138
  if (!existsSync(layoutVersionFile()))
111
- writeFileSync(layoutVersionFile(), `${LAYOUT_VERSION}\n`, { flag: 'wx', mode: 0o600 });
139
+ publishFileExclusive(layoutVersionFile(), `${LAYOUT_VERSION}\n`, 0o600);
112
140
  }
@@ -15,6 +15,7 @@
15
15
  import { chmodSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, closeSync } from 'node:fs';
16
16
  import { dirname } from 'node:path';
17
17
  import lockfile from 'proper-lockfile';
18
+ import { publishFileExclusive } from './fs-utils.js';
18
19
  const WRITE_OPTIONS = { encoding: 'utf-8', mode: 0o600 };
19
20
  // proper-lockfile decides a held lock is abandoned when its lockdir mtime is older than
20
21
  // the acquirer's `stale`, and refreshes the mtime on the holder's `update` timer. The holder
@@ -39,7 +40,9 @@ export class LockedJsonFile {
39
40
  if (!existsSync(dir))
40
41
  mkdirSync(dir, { recursive: true, mode: 0o700 });
41
42
  if (!existsSync(this.path)) {
42
- writeFileSync(this.path, this.initialContent, WRITE_OPTIONS);
43
+ // Published whole and exclusively: another process may be creating it too, and a
44
+ // reader (or a lost write) must never see the empty file a plain create leaves.
45
+ publishFileExclusive(this.path, this.initialContent, 0o600);
43
46
  chmodSync(this.path, 0o600);
44
47
  }
45
48
  }
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import lockfile from 'proper-lockfile';
6
6
  import { CONFIG_FILE, PROFILE_DIR, STATE_FILE } from '../../types.js';
7
7
  import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
8
+ import { syncDirectory, syncFile } from '../fs-utils.js';
8
9
  import { layoutNeedsMigration } from '../layout.js';
9
10
  import { providerStore, projectStore, userStore } from './paths.js';
10
11
  import { createScopedSchema, ensureProviderModelCooldowns, PROVIDER_SCHEMA_VERSION, SCOPED_SCHEMA_VERSION } from './schema.js';
@@ -218,6 +219,11 @@ export function createScopedStore(store, create) {
218
219
  secure(store.path);
219
220
  db.close();
220
221
  }
222
+ // Closing checkpoints the WAL into the database file. Force the finished file and its
223
+ // directory entry to stable storage so a volume handed off right after first boot never
224
+ // comes back with the empty, schema-less file that `scoped SQL store is uninitialized` names.
225
+ syncFile(store.path);
226
+ syncDirectory(store.root);
221
227
  }
222
228
  export function withScopedWrite(store, run) {
223
229
  const db = openScopedStore(store);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/api",
3
- "version": "0.3.441",
3
+ "version": "0.3.442",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, the CrtrClient, and the command-plugin manifest format. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",