@portel/photon-core 2.21.0 → 2.23.0
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/base.d.ts +19 -0
- package/dist/base.d.ts.map +1 -1
- package/dist/base.js +29 -0
- package/dist/base.js.map +1 -1
- package/dist/bases-registry.d.ts +57 -0
- package/dist/bases-registry.d.ts.map +1 -0
- package/dist/bases-registry.js +127 -0
- package/dist/bases-registry.js.map +1 -0
- package/dist/data-paths.d.ts +31 -18
- package/dist/data-paths.d.ts.map +1 -1
- package/dist/data-paths.js +42 -43
- package/dist/data-paths.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +81 -1
- package/dist/memory.js.map +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +35 -0
- package/dist/middleware.js.map +1 -1
- package/dist/path-resolver.d.ts +13 -1
- package/dist/path-resolver.d.ts.map +1 -1
- package/dist/path-resolver.js +23 -1
- package/dist/path-resolver.js.map +1 -1
- package/dist/photon-loader-lite.d.ts +17 -2
- package/dist/photon-loader-lite.d.ts.map +1 -1
- package/dist/photon-loader-lite.js +162 -26
- package/dist/photon-loader-lite.js.map +1 -1
- package/dist/schema-extractor.d.ts +7 -2
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +32 -3
- package/dist/schema-extractor.js.map +1 -1
- package/dist/stateful.d.ts +3 -2
- package/dist/stateful.d.ts.map +1 -1
- package/dist/stateful.js +18 -6
- package/dist/stateful.js.map +1 -1
- package/package.json +2 -2
- package/src/base.ts +43 -0
- package/src/bases-registry.ts +141 -0
- package/src/data-paths.ts +43 -49
- package/src/index.ts +14 -1
- package/src/memory.ts +81 -0
- package/src/middleware.ts +49 -0
- package/src/path-resolver.ts +26 -1
- package/src/photon-loader-lite.ts +176 -33
- package/src/schema-extractor.ts +36 -3
- package/src/stateful.ts +19 -6
package/src/base.ts
CHANGED
|
@@ -636,11 +636,45 @@ export class Photon {
|
|
|
636
636
|
return result;
|
|
637
637
|
} catch (error: any) {
|
|
638
638
|
console.error(`Tool execution failed: ${toolName} - ${error.message}`);
|
|
639
|
+
await this._invokeErrorHook(error, { tool: toolName, params: parameters });
|
|
639
640
|
throw error;
|
|
640
641
|
}
|
|
641
642
|
});
|
|
642
643
|
}
|
|
643
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Invoke the onError observability hook with a bounded timeout. Never
|
|
647
|
+
* suppresses the original error and never throws itself — a throw or
|
|
648
|
+
* timeout inside the hook is logged and swallowed so observability code
|
|
649
|
+
* can never cascade into the request path.
|
|
650
|
+
*/
|
|
651
|
+
private async _invokeErrorHook(
|
|
652
|
+
error: unknown,
|
|
653
|
+
ctx: { tool: string; params: any }
|
|
654
|
+
): Promise<void> {
|
|
655
|
+
const hook = (this as any).onError;
|
|
656
|
+
if (typeof hook !== 'function') return;
|
|
657
|
+
const TIMEOUT_MS = 5000;
|
|
658
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
659
|
+
try {
|
|
660
|
+
await Promise.race([
|
|
661
|
+
Promise.resolve(hook.call(this, error, ctx)),
|
|
662
|
+
new Promise<never>((_, reject) => {
|
|
663
|
+
timer = setTimeout(
|
|
664
|
+
() => reject(new Error(`onError hook exceeded ${TIMEOUT_MS}ms`)),
|
|
665
|
+
TIMEOUT_MS
|
|
666
|
+
);
|
|
667
|
+
}),
|
|
668
|
+
]);
|
|
669
|
+
} catch (hookError: any) {
|
|
670
|
+
console.error(
|
|
671
|
+
`onError hook failed for ${ctx.tool}: ${hookError?.message ?? String(hookError)}`
|
|
672
|
+
);
|
|
673
|
+
} finally {
|
|
674
|
+
if (timer) clearTimeout(timer);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
644
678
|
/**
|
|
645
679
|
* Optional lifecycle hooks
|
|
646
680
|
*/
|
|
@@ -654,6 +688,15 @@ export class Photon {
|
|
|
654
688
|
* During hot-reload, receives context so you can skip resource cleanup.
|
|
655
689
|
*/
|
|
656
690
|
async onShutdown?(ctx?: { reason?: string }): Promise<void>;
|
|
691
|
+
/**
|
|
692
|
+
* Called when any tool method throws. Observability only — the hook
|
|
693
|
+
* cannot suppress or transform the error. A throw or timeout inside
|
|
694
|
+
* the hook is logged and swallowed. Default timeout is 5s.
|
|
695
|
+
*
|
|
696
|
+
* Use it for centralized logging, metrics, or error reporting instead
|
|
697
|
+
* of wrapping every method in try/catch.
|
|
698
|
+
*/
|
|
699
|
+
async onError?(error: unknown, ctx: { tool: string; params: any }): Promise<void>;
|
|
657
700
|
|
|
658
701
|
/**
|
|
659
702
|
* Get an MCP client for calling external MCP servers
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bases Registry
|
|
3
|
+
*
|
|
4
|
+
* Tracks every PHOTON_DIR the daemon has served. The daemon upserts an
|
|
5
|
+
* entry on every photon invocation and, on startup, scans each surviving
|
|
6
|
+
* base for long-lived per-photon state (schedules, etc.) that must be
|
|
7
|
+
* reinstated across restarts.
|
|
8
|
+
*
|
|
9
|
+
* The registry itself is daemon infrastructure and lives at the global
|
|
10
|
+
* path `~/.photon/.data/.bases.json`. The data it points at is NOT — each
|
|
11
|
+
* listed base owns its own data under `{base}/.data/`.
|
|
12
|
+
*
|
|
13
|
+
* See docs/internals/PHOTON-DIR-AND-NAMESPACE.md §8.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { getBasesRegistryPath } from './data-paths.js';
|
|
19
|
+
|
|
20
|
+
export interface BaseRegistryEntry {
|
|
21
|
+
/** Absolute, resolved path to the PHOTON_DIR. */
|
|
22
|
+
path: string;
|
|
23
|
+
/** ISO-8601 timestamp of first record. */
|
|
24
|
+
firstSeen: string;
|
|
25
|
+
/** ISO-8601 timestamp of most recent invocation. */
|
|
26
|
+
lastSeen: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BasesRegistry {
|
|
30
|
+
bases: BaseRegistryEntry[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const EMPTY_REGISTRY: BasesRegistry = { bases: [] };
|
|
34
|
+
|
|
35
|
+
function normalizeBase(basePath: string): string {
|
|
36
|
+
return path.resolve(basePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the registry from disk. Returns an empty registry if the file is
|
|
41
|
+
* missing or malformed. Never throws for absent data.
|
|
42
|
+
*/
|
|
43
|
+
export function readBasesRegistry(): BasesRegistry {
|
|
44
|
+
const file = getBasesRegistryPath();
|
|
45
|
+
let raw: string;
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
48
|
+
} catch {
|
|
49
|
+
return { bases: [] };
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (!parsed || !Array.isArray(parsed.bases)) return { bases: [] };
|
|
54
|
+
// Trust the shape but filter out entries that are obviously broken.
|
|
55
|
+
const bases = parsed.bases.filter(
|
|
56
|
+
(b: any) =>
|
|
57
|
+
b &&
|
|
58
|
+
typeof b.path === 'string' &&
|
|
59
|
+
typeof b.firstSeen === 'string' &&
|
|
60
|
+
typeof b.lastSeen === 'string'
|
|
61
|
+
);
|
|
62
|
+
return { bases };
|
|
63
|
+
} catch {
|
|
64
|
+
return { bases: [] };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write the registry to disk atomically. Creates the parent directory if
|
|
70
|
+
* needed. Uses a temp-file + rename pattern so readers never observe a
|
|
71
|
+
* half-written file.
|
|
72
|
+
*/
|
|
73
|
+
export function writeBasesRegistry(registry: BasesRegistry): void {
|
|
74
|
+
const file = getBasesRegistryPath();
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
77
|
+
fs.writeFileSync(tmp, JSON.stringify(registry, null, 2));
|
|
78
|
+
fs.renameSync(tmp, file);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Upsert a base with a fresh `lastSeen`. Sets `firstSeen` on new entries.
|
|
83
|
+
* Safe to call on every photon invocation — cost is one JSON round-trip
|
|
84
|
+
* per call.
|
|
85
|
+
*/
|
|
86
|
+
export function touchBase(basePath: string, now: Date = new Date()): void {
|
|
87
|
+
const normalized = normalizeBase(basePath);
|
|
88
|
+
const iso = now.toISOString();
|
|
89
|
+
const registry = readBasesRegistry();
|
|
90
|
+
const existing = registry.bases.find((b) => b.path === normalized);
|
|
91
|
+
if (existing) {
|
|
92
|
+
existing.lastSeen = iso;
|
|
93
|
+
} else {
|
|
94
|
+
registry.bases.push({ path: normalized, firstSeen: iso, lastSeen: iso });
|
|
95
|
+
}
|
|
96
|
+
writeBasesRegistry(registry);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Return registry entries whose path still exists on disk as a directory.
|
|
101
|
+
* Entries whose path cannot be confirmed (permission errors, unmounted
|
|
102
|
+
* drives, transient failures) are KEPT — we only filter on confirmed
|
|
103
|
+
* absence, so a momentarily-unreachable path does not get silently
|
|
104
|
+
* dropped.
|
|
105
|
+
*/
|
|
106
|
+
export function listActiveBases(): BaseRegistryEntry[] {
|
|
107
|
+
const registry = readBasesRegistry();
|
|
108
|
+
return registry.bases.filter((b) => {
|
|
109
|
+
try {
|
|
110
|
+
return fs.statSync(b.path).isDirectory();
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
// Only treat "does not exist" as inactive. Other errors keep the entry.
|
|
113
|
+
return err?.code !== 'ENOENT';
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Remove entries whose path is confirmed absent on disk (ENOENT only).
|
|
120
|
+
* Writes the pruned registry back. Returns the list of entries that were
|
|
121
|
+
* removed so callers can log them.
|
|
122
|
+
*/
|
|
123
|
+
export function pruneBasesRegistry(): BaseRegistryEntry[] {
|
|
124
|
+
const registry = readBasesRegistry();
|
|
125
|
+
const removed: BaseRegistryEntry[] = [];
|
|
126
|
+
const kept: BaseRegistryEntry[] = [];
|
|
127
|
+
for (const b of registry.bases) {
|
|
128
|
+
let confirmedAbsent = false;
|
|
129
|
+
try {
|
|
130
|
+
fs.statSync(b.path);
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
if (err?.code === 'ENOENT') confirmedAbsent = true;
|
|
133
|
+
}
|
|
134
|
+
if (confirmedAbsent) removed.push(b);
|
|
135
|
+
else kept.push(b);
|
|
136
|
+
}
|
|
137
|
+
if (removed.length > 0) {
|
|
138
|
+
writeBasesRegistry({ bases: kept });
|
|
139
|
+
}
|
|
140
|
+
return removed;
|
|
141
|
+
}
|
package/src/data-paths.ts
CHANGED
|
@@ -31,10 +31,16 @@ import * as path from 'path';
|
|
|
31
31
|
import * as os from 'os';
|
|
32
32
|
import { execSync } from 'child_process';
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Default PHOTON_DIR when none is explicitly resolved: the user's home
|
|
36
|
+
* `.photon` directory. Single source of truth — path-resolver.ts and
|
|
37
|
+
* photon/src/context.ts re-export this so there is exactly one constant
|
|
38
|
+
* for the concept.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_PHOTON_DIR = path.join(os.homedir(), '.photon');
|
|
35
41
|
|
|
36
42
|
function getBase(baseDir?: string): string {
|
|
37
|
-
return baseDir || process.env.PHOTON_DIR ||
|
|
43
|
+
return baseDir || process.env.PHOTON_DIR || DEFAULT_PHOTON_DIR;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
// ── Root ─────────────────────────────────────────────────────────────────────
|
|
@@ -142,17 +148,33 @@ export function getDaemonSocketPath(): string {
|
|
|
142
148
|
if (process.platform === 'win32') {
|
|
143
149
|
return '\\\\.\\pipe\\photon-daemon';
|
|
144
150
|
}
|
|
145
|
-
return path.join(
|
|
151
|
+
return path.join(DEFAULT_PHOTON_DIR, '.data', 'daemon.sock');
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
/** Daemon PID file: always ~/.photon/.data/daemon.pid */
|
|
149
155
|
export function getDaemonPidPath(): string {
|
|
150
|
-
return path.join(
|
|
156
|
+
return path.join(DEFAULT_PHOTON_DIR, '.data', 'daemon.pid');
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
/** Daemon log file: always ~/.photon/.data/daemon.log */
|
|
154
160
|
export function getDaemonLogPath(): string {
|
|
155
|
-
return path.join(
|
|
161
|
+
return path.join(DEFAULT_PHOTON_DIR, '.data', 'daemon.log');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Bases registry: always ~/.photon/.data/.bases.json (global, one per user).
|
|
166
|
+
*
|
|
167
|
+
* The daemon maintains this file to track every PHOTON_DIR it has served.
|
|
168
|
+
* On startup it reads the registry and scans each base for schedules and
|
|
169
|
+
* other long-lived per-photon state that must survive daemon restarts.
|
|
170
|
+
*
|
|
171
|
+
* The `PHOTON_BASES_REGISTRY` env var exists as a test-only override;
|
|
172
|
+
* production always uses the fixed global path.
|
|
173
|
+
*
|
|
174
|
+
* See docs/internals/PHOTON-DIR-AND-NAMESPACE.md §8.
|
|
175
|
+
*/
|
|
176
|
+
export function getBasesRegistryPath(): string {
|
|
177
|
+
return process.env.PHOTON_BASES_REGISTRY ?? path.join(DEFAULT_PHOTON_DIR, '.data', '.bases.json');
|
|
156
178
|
}
|
|
157
179
|
|
|
158
180
|
// ── Namespace Detection ──────────────────────────────────────────────────────
|
|
@@ -165,6 +187,11 @@ export function getDaemonLogPath(): string {
|
|
|
165
187
|
* git@github.com:portel-dev/photons.git → 'portel-dev'
|
|
166
188
|
* https://github.com/arul-kumar/my-photons → 'arul-kumar'
|
|
167
189
|
* (no git remote) → ''
|
|
190
|
+
*
|
|
191
|
+
* @deprecated Under Option B the namespace is a pure function of the
|
|
192
|
+
* photon file's position relative to PHOTON_DIR; git state must never
|
|
193
|
+
* influence data paths. Scheduled for removal in the next minor release.
|
|
194
|
+
* See docs/internals/PHOTON-DIR-AND-NAMESPACE.md §3.
|
|
168
195
|
*/
|
|
169
196
|
export function detectNamespace(dir: string): string {
|
|
170
197
|
try {
|
|
@@ -228,62 +255,29 @@ export function getLegacySessionMemoryDir(sessionId: string, photonName: string,
|
|
|
228
255
|
return path.join(getBase(baseDir), 'sessions', safeSession, safeName);
|
|
229
256
|
}
|
|
230
257
|
|
|
231
|
-
/** Old runs dir:
|
|
232
|
-
export function getLegacyRunsDir(): string {
|
|
233
|
-
return path.join(
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Old logs dir: ~/.photon/logs/{photonId}/ */
|
|
237
|
-
export function getLegacyLogsDir(photonName: string): string {
|
|
238
|
-
return path.join(DEFAULT_BASE, 'logs', photonName);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Old tasks dir: ~/.photon/tasks/ */
|
|
242
|
-
export function getLegacyTasksDir(): string {
|
|
243
|
-
return path.join(DEFAULT_BASE, 'tasks');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** Old audit path: ~/.photon/audit.jsonl */
|
|
247
|
-
export function getLegacyAuditPath(): string {
|
|
248
|
-
return path.join(DEFAULT_BASE, 'audit.jsonl');
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Old metadata path: {baseDir}/.metadata.json */
|
|
252
|
-
export function getLegacyMetadataPath(baseDir?: string): string {
|
|
253
|
-
return path.join(getBase(baseDir), '.metadata.json');
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Old daemon socket: ~/.photon/daemon.sock */
|
|
257
|
-
export function getLegacyDaemonSocketPath(): string {
|
|
258
|
-
if (process.platform === 'win32') {
|
|
259
|
-
return '\\\\.\\pipe\\photon-daemon';
|
|
260
|
-
}
|
|
261
|
-
return path.join(DEFAULT_BASE, 'daemon.sock');
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** Old daemon PID: ~/.photon/daemon.pid */
|
|
265
|
-
export function getLegacyDaemonPidPath(): string {
|
|
266
|
-
return path.join(DEFAULT_BASE, 'daemon.pid');
|
|
258
|
+
/** Old runs dir: {baseDir}/runs/ */
|
|
259
|
+
export function getLegacyRunsDir(baseDir?: string): string {
|
|
260
|
+
return path.join(getBase(baseDir), 'runs');
|
|
267
261
|
}
|
|
268
262
|
|
|
269
|
-
/** Old
|
|
270
|
-
export function
|
|
271
|
-
return path.join(
|
|
263
|
+
/** Old logs dir: {baseDir}/logs/{photonId}/ */
|
|
264
|
+
export function getLegacyLogsDir(photonName: string, baseDir?: string): string {
|
|
265
|
+
return path.join(getBase(baseDir), 'logs', photonName);
|
|
272
266
|
}
|
|
273
267
|
|
|
274
|
-
/** Old
|
|
275
|
-
export function
|
|
276
|
-
return path.join(getBase(baseDir), '
|
|
268
|
+
/** Old tasks dir: {baseDir}/tasks/ */
|
|
269
|
+
export function getLegacyTasksDir(baseDir?: string): string {
|
|
270
|
+
return path.join(getBase(baseDir), 'tasks');
|
|
277
271
|
}
|
|
278
272
|
|
|
279
273
|
/** Old schedules dir: ~/.photon/schedules/{photonName}/ */
|
|
280
274
|
export function getLegacySchedulesDir(photonName: string): string {
|
|
281
275
|
const safeName = photonName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
282
|
-
return path.join(
|
|
276
|
+
return path.join(DEFAULT_PHOTON_DIR, 'schedules', safeName);
|
|
283
277
|
}
|
|
284
278
|
|
|
285
279
|
/** Old per-photon config: ~/.photon/{photonName}/config.json */
|
|
286
280
|
export function getLegacyPhotonConfigPath(photonName: string): string {
|
|
287
281
|
const safeName = photonName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
288
|
-
return path.join(
|
|
282
|
+
return path.join(DEFAULT_PHOTON_DIR, safeName, 'config.json');
|
|
289
283
|
}
|
package/src/index.ts
CHANGED
|
@@ -172,6 +172,7 @@ export {
|
|
|
172
172
|
resolvePhotonPath,
|
|
173
173
|
listPhotonFiles,
|
|
174
174
|
listPhotonFilesWithNamespace,
|
|
175
|
+
listPhotonSourceFiles,
|
|
175
176
|
ensurePhotonDir,
|
|
176
177
|
DEFAULT_PHOTON_DIR,
|
|
177
178
|
type ResolverOptions,
|
|
@@ -494,7 +495,14 @@ export {
|
|
|
494
495
|
|
|
495
496
|
// ===== PHOTON LOADER LITE =====
|
|
496
497
|
// Direct TypeScript API for loading .photon.ts files with full enhancements
|
|
497
|
-
export {
|
|
498
|
+
export {
|
|
499
|
+
photon,
|
|
500
|
+
clearPhotonCache,
|
|
501
|
+
disposePhoton,
|
|
502
|
+
disposeAllPhotons,
|
|
503
|
+
type PhotonOptions,
|
|
504
|
+
type PhotonEvent,
|
|
505
|
+
} from './photon-loader-lite.js';
|
|
498
506
|
|
|
499
507
|
// ===== FILE WATCHING =====
|
|
500
508
|
// Reusable photon file watcher with symlink resolution, debouncing, rename handling
|
|
@@ -592,3 +600,8 @@ export {
|
|
|
592
600
|
// ===== DATA PATHS =====
|
|
593
601
|
// Central data path resolver — single source of truth for all runtime data locations
|
|
594
602
|
export * from './data-paths.js';
|
|
603
|
+
|
|
604
|
+
// ===== BASES REGISTRY =====
|
|
605
|
+
// Daemon-owned registry of every PHOTON_DIR served. Used to discover schedules
|
|
606
|
+
// and other per-base data on daemon startup. See data-paths getBasesRegistryPath.
|
|
607
|
+
export * from './bases-registry.js';
|
package/src/memory.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
getLegacyMemoryDir,
|
|
28
28
|
getLegacyGlobalMemoryDir,
|
|
29
29
|
getLegacySessionMemoryDir,
|
|
30
|
+
getDataRoot,
|
|
30
31
|
} from './data-paths.js';
|
|
31
32
|
|
|
32
33
|
export type MemoryScope = 'photon' | 'session' | 'global';
|
|
@@ -216,6 +217,83 @@ export class FileMemoryBackend implements MemoryBackend {
|
|
|
216
217
|
// SCOPE RESOLUTION
|
|
217
218
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
218
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Compatibility shim — one-release migration only.
|
|
222
|
+
*
|
|
223
|
+
* Under docs/internals/PHOTON-DIR-AND-NAMESPACE.md the namespace comes
|
|
224
|
+
* purely from directory position and never changes silently. But installs
|
|
225
|
+
* that ran with the old git-remote-based namespace detection may have data
|
|
226
|
+
* sitting under a stale namespace bucket. On first read, if the canonical
|
|
227
|
+
* dir is empty but exactly one sibling namespace contains this photon's
|
|
228
|
+
* memory, migrate it atomically to the canonical location.
|
|
229
|
+
*
|
|
230
|
+
* Multiple matches → ambiguous; warn to stderr so the user can consolidate
|
|
231
|
+
* manually rather than proceeding with empty memory.
|
|
232
|
+
*
|
|
233
|
+
* @deprecated Remove this helper and its caller one release after the
|
|
234
|
+
* PHOTON_DIR-and-namespace change ships.
|
|
235
|
+
*/
|
|
236
|
+
function findAndMigrateStrandedMemory(
|
|
237
|
+
photonId: string,
|
|
238
|
+
canonicalDir: string,
|
|
239
|
+
baseDir?: string
|
|
240
|
+
): string | null {
|
|
241
|
+
const dataRoot = getDataRoot(baseDir);
|
|
242
|
+
let entries: string[];
|
|
243
|
+
try {
|
|
244
|
+
entries = fsSync.readdirSync(dataRoot);
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const matches: string[] = [];
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
// Skip non-namespace buckets. `_global`, `_sessions`, `.cache`, `tasks`
|
|
252
|
+
// live at the same level but are reserved names.
|
|
253
|
+
if (entry.startsWith('_') || entry.startsWith('.') || entry === 'tasks') continue;
|
|
254
|
+
const candidate = path.join(dataRoot, entry, photonId, 'memory');
|
|
255
|
+
try {
|
|
256
|
+
const stat = fsSync.statSync(candidate);
|
|
257
|
+
if (stat.isDirectory()) {
|
|
258
|
+
// Only count non-empty dirs as real data.
|
|
259
|
+
if (fsSync.readdirSync(candidate).length > 0) matches.push(candidate);
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
// Not a match, continue.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (matches.length === 0) return null;
|
|
267
|
+
if (matches.length > 1) {
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`[photon] warning: photon '${photonId}' has memory data stranded under multiple namespaces:\n` +
|
|
270
|
+
matches.map((m) => ` - ${m}`).join('\n') +
|
|
271
|
+
`\n Canonical path is ${canonicalDir}.\n` +
|
|
272
|
+
` Move the correct one into the canonical path and delete the others to consolidate.\n`
|
|
273
|
+
);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const stranded = matches[0];
|
|
278
|
+
try {
|
|
279
|
+
fsSync.mkdirSync(path.dirname(canonicalDir), { recursive: true });
|
|
280
|
+
fsSync.renameSync(stranded, canonicalDir);
|
|
281
|
+
// Clean up now-empty parent if it has no siblings.
|
|
282
|
+
try {
|
|
283
|
+
const strandedParent = path.dirname(stranded);
|
|
284
|
+
if (fsSync.readdirSync(strandedParent).length === 0) fsSync.rmdirSync(strandedParent);
|
|
285
|
+
const strandedNs = path.dirname(strandedParent);
|
|
286
|
+
if (fsSync.readdirSync(strandedNs).length === 0) fsSync.rmdirSync(strandedNs);
|
|
287
|
+
} catch {
|
|
288
|
+
// Non-fatal: leave parent dirs if cleanup fails.
|
|
289
|
+
}
|
|
290
|
+
return canonicalDir;
|
|
291
|
+
} catch {
|
|
292
|
+
// Cross-device rename or permission issue — fall back to reading in place.
|
|
293
|
+
return stranded;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
219
297
|
function resolveDir(
|
|
220
298
|
photonId: string,
|
|
221
299
|
namespace: string,
|
|
@@ -229,6 +307,9 @@ function resolveDir(
|
|
|
229
307
|
if (!fsSync.existsSync(newDir)) {
|
|
230
308
|
const legacyDir = getLegacyMemoryDir(photonId, baseDir);
|
|
231
309
|
if (fsSync.existsSync(legacyDir)) return legacyDir;
|
|
310
|
+
// Last resort: data stranded under a different namespace bucket.
|
|
311
|
+
const recovered = findAndMigrateStrandedMemory(photonId, newDir, baseDir);
|
|
312
|
+
if (recovered) return recovered;
|
|
232
313
|
}
|
|
233
314
|
return newDir;
|
|
234
315
|
}
|
package/src/middleware.ts
CHANGED
|
@@ -626,6 +626,54 @@ const retryableMiddleware = defineMiddleware<{ count: number; delay: number }>({
|
|
|
626
626
|
},
|
|
627
627
|
});
|
|
628
628
|
|
|
629
|
+
// --- bulkhead (phase 15) ---
|
|
630
|
+
// Caps concurrent in-flight executions per photon:instance:tool. Unlike
|
|
631
|
+
// @throttled (which rate-limits over a time window), bulkhead protects
|
|
632
|
+
// downstream resources from being overwhelmed by concurrent load.
|
|
633
|
+
// Fast-fails when the cap is hit — callers should back off or queue.
|
|
634
|
+
|
|
635
|
+
interface BulkheadStateEntry {
|
|
636
|
+
inFlight: number;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const bulkheadMiddleware = defineMiddleware<{ maxConcurrent: number }>({
|
|
640
|
+
name: 'bulkhead',
|
|
641
|
+
phase: 15,
|
|
642
|
+
parseShorthand(value: string) {
|
|
643
|
+
return { maxConcurrent: Math.max(1, parseInt(value.trim(), 10) || 1) };
|
|
644
|
+
},
|
|
645
|
+
parseConfig(raw) {
|
|
646
|
+
return {
|
|
647
|
+
maxConcurrent: Math.max(1, parseInt(raw.maxConcurrent || raw.max || '1', 10)),
|
|
648
|
+
};
|
|
649
|
+
},
|
|
650
|
+
create(config, state) {
|
|
651
|
+
return async (ctx, next) => {
|
|
652
|
+
const key = `${ctx.photon}:${ctx.instance}:${ctx.tool}`;
|
|
653
|
+
let entry = state.get<BulkheadStateEntry>(key);
|
|
654
|
+
if (!entry) {
|
|
655
|
+
entry = { inFlight: 0 };
|
|
656
|
+
state.set(key, entry);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (entry.inFlight >= config.maxConcurrent) {
|
|
660
|
+
const error = new Error(
|
|
661
|
+
`Bulkhead full: ${ctx.photon}.${ctx.tool} has ${entry.inFlight} concurrent executions (cap: ${config.maxConcurrent})`
|
|
662
|
+
);
|
|
663
|
+
error.name = 'PhotonBulkheadFullError';
|
|
664
|
+
throw error;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
entry.inFlight++;
|
|
668
|
+
try {
|
|
669
|
+
return await next();
|
|
670
|
+
} finally {
|
|
671
|
+
entry.inFlight = Math.max(0, entry.inFlight - 1);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
|
|
629
677
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
630
678
|
// GLOBAL BUILT-IN REGISTRY
|
|
631
679
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -634,6 +682,7 @@ export const builtinRegistry = new MiddlewareRegistry();
|
|
|
634
682
|
builtinRegistry.register(fallbackMiddleware);
|
|
635
683
|
builtinRegistry.register(loggedMiddleware);
|
|
636
684
|
builtinRegistry.register(circuitBreakerMiddleware);
|
|
685
|
+
builtinRegistry.register(bulkheadMiddleware);
|
|
637
686
|
builtinRegistry.register(throttledMiddleware);
|
|
638
687
|
builtinRegistry.register(debouncedMiddleware);
|
|
639
688
|
builtinRegistry.register(cachedMiddleware);
|
package/src/path-resolver.ts
CHANGED
|
@@ -18,7 +18,10 @@ import * as fsSync from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import * as os from 'os';
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
import { DEFAULT_PHOTON_DIR } from './data-paths.js';
|
|
22
|
+
// Re-exported so existing `import { DEFAULT_PHOTON_DIR } from '@portel/photon-core'`
|
|
23
|
+
// callers keep working. One canonical definition lives in data-paths.ts.
|
|
24
|
+
export { DEFAULT_PHOTON_DIR };
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Expand tilde (~) to user's home directory
|
|
@@ -53,6 +56,28 @@ const SKIP_DIRS = new Set([
|
|
|
53
56
|
'node_modules', 'marketplace', 'photons', 'templates',
|
|
54
57
|
]);
|
|
55
58
|
|
|
59
|
+
/**
|
|
60
|
+
* List photon source file names in a directory. Returns just filenames
|
|
61
|
+
* (not full paths) so callers can choose to either join with the dir
|
|
62
|
+
* or operate on the basename directly. Missing/unreadable dirs return [].
|
|
63
|
+
*
|
|
64
|
+
* Replaces scattered `readdirSync(dir).filter(f => f.endsWith('.photon.ts'))`
|
|
65
|
+
* expressions so the default extension list stays consistent.
|
|
66
|
+
*/
|
|
67
|
+
export function listPhotonSourceFiles(
|
|
68
|
+
dir: string,
|
|
69
|
+
options?: { extensions?: string[] },
|
|
70
|
+
): string[] {
|
|
71
|
+
const extensions = options?.extensions ?? defaultOptions.extensions;
|
|
72
|
+
try {
|
|
73
|
+
return fsSync
|
|
74
|
+
.readdirSync(dir)
|
|
75
|
+
.filter((f) => extensions.some((ext) => f.endsWith(ext)));
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
/**
|
|
57
82
|
* Resolve a file path from name.
|
|
58
83
|
*
|