@reactive-skills/runtime 0.1.1 → 0.3.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/LICENSE +658 -21
- package/README.md +1 -0
- package/dist/core/event-store.d.ts +8 -1
- package/dist/core/event-store.js +97 -6
- package/dist/core/fsm-engine.d.ts +9 -0
- package/dist/core/fsm-engine.js +52 -2
- package/dist/core/job-manager.d.ts +50 -0
- package/dist/core/job-manager.js +213 -0
- package/dist/core/projection-engine.d.ts +4 -1
- package/dist/core/projection-engine.js +29 -7
- package/dist/core/types.d.ts +34 -0
- package/dist/core/types.js +12 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mcp/server.js +69 -20
- package/dist/telemetry/server.d.ts +31 -0
- package/dist/telemetry/server.js +275 -0
- package/dist/telemetry/types.d.ts +57 -0
- package/dist/telemetry/types.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ import { FSMEngine, EventStore, LegacySkillAdapter, ProjectionEngine } from '@re
|
|
|
22
22
|
- `ProjectionEngine` - Handlebars deliverable generator for read-model projections
|
|
23
23
|
- `LegacySkillAdapter` - Backward compatibility wrapper and converter for SKILL.md
|
|
24
24
|
- `McpServer` - Stdio Model Context Protocol (MCP) server integration
|
|
25
|
+
- `TelemetryServer` - Real-time Server-Sent Events (SSE) broadcaster and Private Network Access (PNA) HTTP bridge
|
|
25
26
|
|
|
26
27
|
## Skill Manifest Schema
|
|
27
28
|
|
|
@@ -7,6 +7,7 @@ export interface EventStoreOptions {
|
|
|
7
7
|
sqlitePath?: string;
|
|
8
8
|
workspaceDir?: string;
|
|
9
9
|
skillId?: string;
|
|
10
|
+
jobId?: string;
|
|
10
11
|
runId?: string;
|
|
11
12
|
run_id?: string;
|
|
12
13
|
correlationId?: string;
|
|
@@ -23,6 +24,7 @@ export interface EventStoreOptions {
|
|
|
23
24
|
enableSqlite?: boolean;
|
|
24
25
|
maxInMemoryEvents?: number;
|
|
25
26
|
maxJsonlBytes?: number;
|
|
27
|
+
acquireLock?: boolean;
|
|
26
28
|
}
|
|
27
29
|
export interface EventQueryOptions {
|
|
28
30
|
type?: string;
|
|
@@ -36,6 +38,7 @@ export interface EventQueryOptions {
|
|
|
36
38
|
*/
|
|
37
39
|
export declare class SQLiteStorageDriver {
|
|
38
40
|
private db;
|
|
41
|
+
private isClosed;
|
|
39
42
|
constructor(dbPath: string);
|
|
40
43
|
private initTables;
|
|
41
44
|
private ensureSchemaVersion;
|
|
@@ -93,7 +96,11 @@ export declare class EventStore {
|
|
|
93
96
|
private projectionWatermarks;
|
|
94
97
|
private latestSnapshot;
|
|
95
98
|
private maxJsonlBytes;
|
|
99
|
+
private lockFd;
|
|
100
|
+
private lockPath;
|
|
96
101
|
constructor(options?: EventStoreOptions);
|
|
102
|
+
private acquireJobLock;
|
|
103
|
+
private releaseJobLock;
|
|
97
104
|
private initializeStorage;
|
|
98
105
|
/**
|
|
99
106
|
* Scan all JSONL files for the authoritative max seq and event count.
|
|
@@ -168,7 +175,7 @@ export declare class EventStore {
|
|
|
168
175
|
*/
|
|
169
176
|
query(filter?: EventQueryOptions): SignalEvent[];
|
|
170
177
|
/**
|
|
171
|
-
* Close storage drivers and release file handles
|
|
178
|
+
* Close storage drivers and release file handles and locks
|
|
172
179
|
*/
|
|
173
180
|
close(): void;
|
|
174
181
|
/**
|
package/dist/core/event-store.js
CHANGED
|
@@ -20,6 +20,7 @@ export function createSortableId() {
|
|
|
20
20
|
*/
|
|
21
21
|
export class SQLiteStorageDriver {
|
|
22
22
|
db;
|
|
23
|
+
isClosed = false;
|
|
23
24
|
constructor(dbPath) {
|
|
24
25
|
if (dbPath !== ':memory:') {
|
|
25
26
|
const dir = path.dirname(dbPath);
|
|
@@ -237,7 +238,17 @@ export class SQLiteStorageDriver {
|
|
|
237
238
|
return row ? row.content : null;
|
|
238
239
|
}
|
|
239
240
|
close() {
|
|
240
|
-
this.
|
|
241
|
+
if (this.isClosed)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
this.db.close();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// already closed
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.isClosed = true;
|
|
251
|
+
}
|
|
241
252
|
}
|
|
242
253
|
static getSchemaVersion(dbPath) {
|
|
243
254
|
const db = new DatabaseSync(dbPath);
|
|
@@ -334,12 +345,15 @@ export class EventStore {
|
|
|
334
345
|
projectionWatermarks = new Map();
|
|
335
346
|
latestSnapshot = null;
|
|
336
347
|
maxJsonlBytes;
|
|
348
|
+
lockFd = null;
|
|
349
|
+
lockPath = null;
|
|
337
350
|
constructor(options = {}) {
|
|
338
351
|
this.maxInMemoryEvents = options.maxInMemoryEvents || 1000;
|
|
339
352
|
this.maxJsonlBytes = options.maxJsonlBytes || 10 * 1024 * 1024;
|
|
353
|
+
const effectiveJobId = options.jobId || options.runId || options.run_id;
|
|
340
354
|
this.eventContext = {
|
|
341
355
|
skill_id: options.skillId,
|
|
342
|
-
run_id:
|
|
356
|
+
run_id: effectiveJobId || createSortableId(),
|
|
343
357
|
correlation_id: options.correlationId || createSortableId(),
|
|
344
358
|
request_id: options.requestId,
|
|
345
359
|
trace_parent: options.traceParent,
|
|
@@ -351,9 +365,21 @@ export class EventStore {
|
|
|
351
365
|
const scopeDir = options.skillId
|
|
352
366
|
? path.join(workspaceDir, '.reactive', 'skills', options.skillId)
|
|
353
367
|
: path.join(workspaceDir, '.reactive');
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
368
|
+
let runScopedDir = scopeDir;
|
|
369
|
+
if (options.skillId && effectiveJobId) {
|
|
370
|
+
const isLegacyFallback = effectiveJobId === 'default' &&
|
|
371
|
+
!fs.existsSync(path.join(scopeDir, 'jobs', effectiveJobId)) &&
|
|
372
|
+
fs.existsSync(path.join(scopeDir, 'events.jsonl'));
|
|
373
|
+
if (!isLegacyFallback) {
|
|
374
|
+
runScopedDir = path.join(scopeDir, 'jobs', effectiveJobId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
else if (effectiveJobId) {
|
|
378
|
+
runScopedDir = path.join(scopeDir, effectiveJobId);
|
|
379
|
+
}
|
|
380
|
+
if (options.acquireLock && runScopedDir) {
|
|
381
|
+
this.acquireJobLock(runScopedDir);
|
|
382
|
+
}
|
|
357
383
|
this.storagePath = options.storagePath || path.join(runScopedDir, 'events.jsonl');
|
|
358
384
|
if (options.enableSqlite || options.sqlitePath) {
|
|
359
385
|
const dbPath = options.sqlitePath || path.join(runScopedDir, 'events.db');
|
|
@@ -366,6 +392,69 @@ export class EventStore {
|
|
|
366
392
|
this.sqliteDriver = new SQLiteStorageDriver(':memory:');
|
|
367
393
|
}
|
|
368
394
|
}
|
|
395
|
+
acquireJobLock(dir) {
|
|
396
|
+
if (!fs.existsSync(dir)) {
|
|
397
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
398
|
+
}
|
|
399
|
+
this.lockPath = path.join(dir, '.lock');
|
|
400
|
+
try {
|
|
401
|
+
this.lockFd = fs.openSync(this.lockPath, 'wx');
|
|
402
|
+
fs.writeSync(this.lockFd, JSON.stringify({ pid: process.pid, time: new Date().toISOString() }));
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
if (err.code === 'EEXIST') {
|
|
406
|
+
try {
|
|
407
|
+
const content = fs.readFileSync(this.lockPath, 'utf8');
|
|
408
|
+
const data = JSON.parse(content);
|
|
409
|
+
if (data.pid) {
|
|
410
|
+
try {
|
|
411
|
+
process.kill(data.pid, 0);
|
|
412
|
+
const lockError = new Error(`JOB_LOCKED: Job directory is currently locked by PID ${data.pid}`);
|
|
413
|
+
lockError.code = 'JOB_LOCKED';
|
|
414
|
+
throw lockError;
|
|
415
|
+
}
|
|
416
|
+
catch (killErr) {
|
|
417
|
+
if (killErr.code === 'ESRCH') {
|
|
418
|
+
fs.unlinkSync(this.lockPath);
|
|
419
|
+
this.lockFd = fs.openSync(this.lockPath, 'wx');
|
|
420
|
+
fs.writeSync(this.lockFd, JSON.stringify({ pid: process.pid, time: new Date().toISOString() }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
throw killErr;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (readErr) {
|
|
428
|
+
if (readErr.code === 'JOB_LOCKED')
|
|
429
|
+
throw readErr;
|
|
430
|
+
}
|
|
431
|
+
const lockError = new Error(`JOB_LOCKED: Job directory is currently locked`);
|
|
432
|
+
lockError.code = 'JOB_LOCKED';
|
|
433
|
+
throw lockError;
|
|
434
|
+
}
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
releaseJobLock() {
|
|
439
|
+
if (this.lockFd !== null) {
|
|
440
|
+
try {
|
|
441
|
+
fs.closeSync(this.lockFd);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// ignore
|
|
445
|
+
}
|
|
446
|
+
this.lockFd = null;
|
|
447
|
+
}
|
|
448
|
+
if (this.lockPath && fs.existsSync(this.lockPath)) {
|
|
449
|
+
try {
|
|
450
|
+
fs.unlinkSync(this.lockPath);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
// ignore
|
|
454
|
+
}
|
|
455
|
+
this.lockPath = null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
369
458
|
initializeStorage() {
|
|
370
459
|
// SQLite is authoritative. If it already has events, load the recent
|
|
371
460
|
// sliding window and check JSONL for divergence before returning.
|
|
@@ -727,9 +816,10 @@ export class EventStore {
|
|
|
727
816
|
return filter.limit ? filtered.slice(0, filter.limit) : filtered;
|
|
728
817
|
}
|
|
729
818
|
/**
|
|
730
|
-
* Close storage drivers and release file handles
|
|
819
|
+
* Close storage drivers and release file handles and locks
|
|
731
820
|
*/
|
|
732
821
|
close() {
|
|
822
|
+
this.releaseJobLock();
|
|
733
823
|
if (this.sqliteDriver) {
|
|
734
824
|
this.sqliteDriver.close();
|
|
735
825
|
}
|
|
@@ -748,6 +838,7 @@ export class EventStore {
|
|
|
748
838
|
this.seqCounter = 0;
|
|
749
839
|
this.projectionWatermarks.clear();
|
|
750
840
|
this.latestSnapshot = null;
|
|
841
|
+
this.releaseJobLock();
|
|
751
842
|
if (this.sqliteDriver) {
|
|
752
843
|
this.sqliteDriver.clear();
|
|
753
844
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { SkillManifest, StateDefinition, SignalEvent, PromptSlice, EventContext, ChildRunSummary, DecisionRecord } from './types.js';
|
|
2
2
|
import { EventStore } from './event-store.js';
|
|
3
|
+
import { JobManager } from './job-manager.js';
|
|
3
4
|
export interface FSMEngineOptions {
|
|
4
5
|
skillDir: string;
|
|
5
6
|
workspaceDir?: string;
|
|
6
7
|
eventStore?: EventStore;
|
|
7
8
|
eventContext?: EventContext;
|
|
9
|
+
jobId?: string;
|
|
8
10
|
runId?: string;
|
|
9
11
|
initialContext?: Record<string, any>;
|
|
10
12
|
autoRehydrate?: boolean;
|
|
@@ -23,6 +25,9 @@ export declare class FSMEngine {
|
|
|
23
25
|
private turnsSinceLastSignal;
|
|
24
26
|
private inBypassState;
|
|
25
27
|
private readonly strictExecution;
|
|
28
|
+
private jobId?;
|
|
29
|
+
private isActiveJob;
|
|
30
|
+
private jobManager;
|
|
26
31
|
constructor(options: FSMEngineOptions);
|
|
27
32
|
/**
|
|
28
33
|
* Rehydrate state machine state and context from immutable event history,
|
|
@@ -92,4 +97,8 @@ export declare class FSMEngine {
|
|
|
92
97
|
private enterPath;
|
|
93
98
|
private executeEntryHook;
|
|
94
99
|
private executeExitHook;
|
|
100
|
+
getJobId(): string | undefined;
|
|
101
|
+
isJobActive(): boolean;
|
|
102
|
+
getJobManager(): JobManager;
|
|
103
|
+
close(): void;
|
|
95
104
|
}
|
package/dist/core/fsm-engine.js
CHANGED
|
@@ -7,6 +7,7 @@ import { EventStore, createSortableId } from './event-store.js';
|
|
|
7
7
|
import { GuardEvaluator } from './guard-evaluator.js';
|
|
8
8
|
import { LegacySkillAdapter } from './legacy-adapter.js';
|
|
9
9
|
import { ProjectionEngine } from './projection-engine.js';
|
|
10
|
+
import { JobManager } from './job-manager.js';
|
|
10
11
|
/** Maximum lifecycle-signal drain steps per top-level handleSignal call.
|
|
11
12
|
* Prevents cyclic on_enter/on_exit emissions from overflowing the call stack (REL-02). */
|
|
12
13
|
const MAX_QUEUE_DRAIN_DEPTH = 50;
|
|
@@ -24,6 +25,9 @@ export class FSMEngine {
|
|
|
24
25
|
turnsSinceLastSignal;
|
|
25
26
|
inBypassState;
|
|
26
27
|
strictExecution;
|
|
28
|
+
jobId;
|
|
29
|
+
isActiveJob;
|
|
30
|
+
jobManager;
|
|
27
31
|
constructor(options) {
|
|
28
32
|
this.skillDir = path.resolve(options.skillDir);
|
|
29
33
|
this.workspaceDir = options.workspaceDir || process.cwd();
|
|
@@ -31,10 +35,29 @@ export class FSMEngine {
|
|
|
31
35
|
this.strictExecution = this.manifest.strict_execution === true;
|
|
32
36
|
this.turnsSinceLastSignal = 0;
|
|
33
37
|
this.inBypassState = false;
|
|
38
|
+
const effectiveJobId = options.jobId || options.runId;
|
|
39
|
+
this.jobManager = new JobManager(this.workspaceDir);
|
|
40
|
+
const activeJobId = this.jobManager.getActiveJobId(this.manifest.name);
|
|
41
|
+
const resolvedJobId = effectiveJobId || activeJobId;
|
|
42
|
+
const isActiveJob = (resolvedJobId === activeJobId);
|
|
43
|
+
this.jobId = resolvedJobId;
|
|
44
|
+
this.isActiveJob = isActiveJob;
|
|
45
|
+
if (this.jobId) {
|
|
46
|
+
const existingJob = this.jobManager.getJob(this.manifest.name, this.jobId);
|
|
47
|
+
if (!existingJob) {
|
|
48
|
+
this.jobManager.createJob(this.manifest.name, {
|
|
49
|
+
id: this.jobId,
|
|
50
|
+
name: this.jobId,
|
|
51
|
+
initialState: this.manifest.initial_state,
|
|
52
|
+
setActive: this.isActiveJob,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
34
56
|
this.eventStore = options.eventStore || new EventStore({
|
|
35
57
|
skillId: this.manifest.name,
|
|
36
58
|
workspaceDir: this.workspaceDir,
|
|
37
|
-
|
|
59
|
+
jobId: this.jobId,
|
|
60
|
+
runId: this.jobId,
|
|
38
61
|
enableSqlite: true,
|
|
39
62
|
...options.eventContext,
|
|
40
63
|
});
|
|
@@ -42,7 +65,7 @@ export class FSMEngine {
|
|
|
42
65
|
...(this.manifest.default_context || {}),
|
|
43
66
|
...(options.initialContext || {}),
|
|
44
67
|
};
|
|
45
|
-
this.projectionEngine = new ProjectionEngine(this.skillDir, this.manifest.deliverable_projections || [], options.workspaceDir || process.cwd());
|
|
68
|
+
this.projectionEngine = new ProjectionEngine(this.skillDir, this.manifest.deliverable_projections || [], options.workspaceDir || process.cwd(), this.jobId, this.isActiveJob);
|
|
46
69
|
const autoRehydrate = options.autoRehydrate !== false;
|
|
47
70
|
const latestSnapshot = autoRehydrate ? this.eventStore.getLatestSnapshot() : null;
|
|
48
71
|
const history = autoRehydrate
|
|
@@ -50,6 +73,11 @@ export class FSMEngine {
|
|
|
50
73
|
: [];
|
|
51
74
|
if (autoRehydrate && (latestSnapshot || history.length > 0)) {
|
|
52
75
|
this.rehydrate(history, latestSnapshot);
|
|
76
|
+
if (this.jobId) {
|
|
77
|
+
this.jobManager.updateJob(this.manifest.name, this.jobId, {
|
|
78
|
+
currentState: this.getCurrentState(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
53
81
|
}
|
|
54
82
|
else {
|
|
55
83
|
const initialPath = this.resolveInitialPath([this.manifest.initial_state]);
|
|
@@ -62,6 +90,11 @@ export class FSMEngine {
|
|
|
62
90
|
}, { state: initialPath.join('.') });
|
|
63
91
|
this.enterPath(initialPath, [], 'INITIAL_BOOT');
|
|
64
92
|
this.eventStore.saveSnapshot(this.eventStore.getLatestSequence(), this.getCurrentState(), this.context);
|
|
93
|
+
if (this.jobId) {
|
|
94
|
+
this.jobManager.updateJob(this.manifest.name, this.jobId, {
|
|
95
|
+
currentState: this.getCurrentState(),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
65
98
|
}
|
|
66
99
|
}
|
|
67
100
|
/**
|
|
@@ -531,6 +564,11 @@ export class FSMEngine {
|
|
|
531
564
|
this.signalQueueDepth = 0;
|
|
532
565
|
}
|
|
533
566
|
}
|
|
567
|
+
if (this.jobId) {
|
|
568
|
+
this.jobManager.updateJob(this.manifest.name, this.jobId, {
|
|
569
|
+
currentState: this.getCurrentState(),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
534
572
|
return {
|
|
535
573
|
transitioned: true,
|
|
536
574
|
previousState,
|
|
@@ -648,4 +686,16 @@ export class FSMEngine {
|
|
|
648
686
|
}
|
|
649
687
|
}
|
|
650
688
|
}
|
|
689
|
+
getJobId() {
|
|
690
|
+
return this.jobId;
|
|
691
|
+
}
|
|
692
|
+
isJobActive() {
|
|
693
|
+
return this.isActiveJob;
|
|
694
|
+
}
|
|
695
|
+
getJobManager() {
|
|
696
|
+
return this.jobManager;
|
|
697
|
+
}
|
|
698
|
+
close() {
|
|
699
|
+
this.eventStore.close();
|
|
700
|
+
}
|
|
651
701
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { JobMetadata } from './types.js';
|
|
2
|
+
export declare const DEFAULT_JOB_ID = "default";
|
|
3
|
+
/**
|
|
4
|
+
* Converts arbitrary human strings (e.g. 'Auth Slice v1.0') into safe filesystem slugs.
|
|
5
|
+
*/
|
|
6
|
+
export declare function normalizeJobSlug(name: string): string;
|
|
7
|
+
export interface CreateJobOptions {
|
|
8
|
+
id?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
initialState?: string;
|
|
11
|
+
parentRunId?: string;
|
|
12
|
+
setActive?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* JobManager handles execution run isolation, active job pointer resolution,
|
|
16
|
+
* and job metadata persistence.
|
|
17
|
+
*/
|
|
18
|
+
export declare class JobManager {
|
|
19
|
+
private workspaceDir;
|
|
20
|
+
constructor(workspaceDir?: string);
|
|
21
|
+
getSkillDir(skillId: string): string;
|
|
22
|
+
getActivePointerPath(skillId: string): string;
|
|
23
|
+
getJobsDir(skillId: string): string;
|
|
24
|
+
getJobDir(skillId: string, jobId: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the current active job ID for a skill.
|
|
27
|
+
* Falls back to DEFAULT_JOB_ID ('default') if no pointer exists.
|
|
28
|
+
*/
|
|
29
|
+
getActiveJobId(skillId: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Sets the active job pointer for a skill.
|
|
32
|
+
*/
|
|
33
|
+
setActiveJobId(skillId: string, jobId: string): void;
|
|
34
|
+
/**
|
|
35
|
+
* Creates a new isolated job and persists its job.json metadata.
|
|
36
|
+
*/
|
|
37
|
+
createJob(skillId: string, options?: CreateJobOptions): JobMetadata;
|
|
38
|
+
/**
|
|
39
|
+
* Retrieves a job's metadata by ID.
|
|
40
|
+
*/
|
|
41
|
+
getJob(skillId: string, jobId: string): JobMetadata | null;
|
|
42
|
+
/**
|
|
43
|
+
* Updates a job's metadata (e.g. status, currentState, completedAt).
|
|
44
|
+
*/
|
|
45
|
+
updateJob(skillId: string, jobId: string, updates: Partial<JobMetadata>): JobMetadata;
|
|
46
|
+
/**
|
|
47
|
+
* Lists all jobs for a skill.
|
|
48
|
+
*/
|
|
49
|
+
listJobs(skillId: string): JobMetadata[];
|
|
50
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createSortableId } from './event-store.js';
|
|
4
|
+
import { JobMetadataSchema } from './types.js';
|
|
5
|
+
export const DEFAULT_JOB_ID = 'default';
|
|
6
|
+
/**
|
|
7
|
+
* Converts arbitrary human strings (e.g. 'Auth Slice v1.0') into safe filesystem slugs.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeJobSlug(name) {
|
|
10
|
+
const cleaned = name
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '');
|
|
15
|
+
if (!cleaned) {
|
|
16
|
+
return `job-${createSortableId().slice(0, 8)}`;
|
|
17
|
+
}
|
|
18
|
+
return cleaned;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* JobManager handles execution run isolation, active job pointer resolution,
|
|
22
|
+
* and job metadata persistence.
|
|
23
|
+
*/
|
|
24
|
+
export class JobManager {
|
|
25
|
+
workspaceDir;
|
|
26
|
+
constructor(workspaceDir = process.cwd()) {
|
|
27
|
+
this.workspaceDir = path.resolve(workspaceDir);
|
|
28
|
+
}
|
|
29
|
+
getSkillDir(skillId) {
|
|
30
|
+
return path.join(this.workspaceDir, '.reactive', 'skills', skillId);
|
|
31
|
+
}
|
|
32
|
+
getActivePointerPath(skillId) {
|
|
33
|
+
return path.join(this.getSkillDir(skillId), 'active_job');
|
|
34
|
+
}
|
|
35
|
+
getJobsDir(skillId) {
|
|
36
|
+
return path.join(this.getSkillDir(skillId), 'jobs');
|
|
37
|
+
}
|
|
38
|
+
getJobDir(skillId, jobId) {
|
|
39
|
+
return path.join(this.getJobsDir(skillId), jobId);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolves the current active job ID for a skill.
|
|
43
|
+
* Falls back to DEFAULT_JOB_ID ('default') if no pointer exists.
|
|
44
|
+
*/
|
|
45
|
+
getActiveJobId(skillId) {
|
|
46
|
+
const pointerFile = this.getActivePointerPath(skillId);
|
|
47
|
+
if (fs.existsSync(pointerFile)) {
|
|
48
|
+
try {
|
|
49
|
+
const id = fs.readFileSync(pointerFile, 'utf8').trim();
|
|
50
|
+
if (id)
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Fall back to default
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return DEFAULT_JOB_ID;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Sets the active job pointer for a skill.
|
|
61
|
+
*/
|
|
62
|
+
setActiveJobId(skillId, jobId) {
|
|
63
|
+
const targetDir = this.getSkillDir(skillId);
|
|
64
|
+
if (!fs.existsSync(targetDir)) {
|
|
65
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
const pointerFile = this.getActivePointerPath(skillId);
|
|
68
|
+
fs.writeFileSync(pointerFile, jobId.trim(), 'utf8');
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Creates a new isolated job and persists its job.json metadata.
|
|
72
|
+
*/
|
|
73
|
+
createJob(skillId, options = {}) {
|
|
74
|
+
const id = options.id || createSortableId();
|
|
75
|
+
const name = options.name ? normalizeJobSlug(options.name) : (options.id ? normalizeJobSlug(options.id) : DEFAULT_JOB_ID);
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
const metadata = {
|
|
78
|
+
id,
|
|
79
|
+
name,
|
|
80
|
+
skillId,
|
|
81
|
+
status: 'active',
|
|
82
|
+
currentState: options.initialState || 'INIT',
|
|
83
|
+
parentRunId: options.parentRunId,
|
|
84
|
+
createdAt: now,
|
|
85
|
+
updatedAt: now,
|
|
86
|
+
};
|
|
87
|
+
const jobDir = this.getJobDir(skillId, id);
|
|
88
|
+
if (!fs.existsSync(jobDir)) {
|
|
89
|
+
fs.mkdirSync(jobDir, { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
const metadataPath = path.join(jobDir, 'job.json');
|
|
92
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf8');
|
|
93
|
+
if (options.setActive) {
|
|
94
|
+
this.setActiveJobId(skillId, id);
|
|
95
|
+
}
|
|
96
|
+
return metadata;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Retrieves a job's metadata by ID.
|
|
100
|
+
*/
|
|
101
|
+
getJob(skillId, jobId) {
|
|
102
|
+
const jobDir = this.getJobDir(skillId, jobId);
|
|
103
|
+
const metadataPath = path.join(jobDir, 'job.json');
|
|
104
|
+
if (fs.existsSync(metadataPath)) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
|
|
107
|
+
return JobMetadataSchema.parse(raw);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Fallback: if jobDir exists (e.g. created by EventStore), synthesize metadata
|
|
114
|
+
if (fs.existsSync(jobDir)) {
|
|
115
|
+
const stat = fs.statSync(jobDir);
|
|
116
|
+
return {
|
|
117
|
+
id: jobId,
|
|
118
|
+
name: normalizeJobSlug(jobId),
|
|
119
|
+
skillId,
|
|
120
|
+
status: 'active',
|
|
121
|
+
currentState: 'INIT',
|
|
122
|
+
createdAt: stat.birthtime && !isNaN(stat.birthtime.getTime()) ? stat.birthtime.toISOString() : new Date().toISOString(),
|
|
123
|
+
updatedAt: stat.mtime && !isNaN(stat.mtime.getTime()) ? stat.mtime.toISOString() : new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Check if it's the active job pointer
|
|
127
|
+
if (jobId === this.getActiveJobId(skillId)) {
|
|
128
|
+
const pointerFile = this.getActivePointerPath(skillId);
|
|
129
|
+
if (fs.existsSync(pointerFile)) {
|
|
130
|
+
const stat = fs.statSync(pointerFile);
|
|
131
|
+
return {
|
|
132
|
+
id: jobId,
|
|
133
|
+
name: normalizeJobSlug(jobId),
|
|
134
|
+
skillId,
|
|
135
|
+
status: 'active',
|
|
136
|
+
currentState: 'INIT',
|
|
137
|
+
createdAt: stat.birthtime && !isNaN(stat.birthtime.getTime()) ? stat.birthtime.toISOString() : new Date().toISOString(),
|
|
138
|
+
updatedAt: stat.mtime && !isNaN(stat.mtime.getTime()) ? stat.mtime.toISOString() : new Date().toISOString(),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Legacy fallback: if requesting 'default' and legacy events.jsonl exists at skill root
|
|
143
|
+
if (jobId === DEFAULT_JOB_ID) {
|
|
144
|
+
const legacyEventStore = path.join(this.getSkillDir(skillId), 'events.jsonl');
|
|
145
|
+
if (fs.existsSync(legacyEventStore)) {
|
|
146
|
+
return {
|
|
147
|
+
id: DEFAULT_JOB_ID,
|
|
148
|
+
name: DEFAULT_JOB_ID,
|
|
149
|
+
skillId,
|
|
150
|
+
status: 'active',
|
|
151
|
+
currentState: 'ACTIVE',
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
updatedAt: new Date().toISOString(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Updates a job's metadata (e.g. status, currentState, completedAt).
|
|
161
|
+
*/
|
|
162
|
+
updateJob(skillId, jobId, updates) {
|
|
163
|
+
const existing = this.getJob(skillId, jobId) || this.createJob(skillId, { id: jobId, name: jobId });
|
|
164
|
+
const updated = {
|
|
165
|
+
...existing,
|
|
166
|
+
...updates,
|
|
167
|
+
updatedAt: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
const validated = JobMetadataSchema.parse(updated);
|
|
170
|
+
const jobDir = this.getJobDir(skillId, jobId);
|
|
171
|
+
if (!fs.existsSync(jobDir)) {
|
|
172
|
+
fs.mkdirSync(jobDir, { recursive: true });
|
|
173
|
+
}
|
|
174
|
+
const metadataPath = path.join(jobDir, 'job.json');
|
|
175
|
+
fs.writeFileSync(metadataPath, JSON.stringify(validated, null, 2), 'utf8');
|
|
176
|
+
return validated;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Lists all jobs for a skill.
|
|
180
|
+
*/
|
|
181
|
+
listJobs(skillId) {
|
|
182
|
+
const jobs = [];
|
|
183
|
+
const jobsDir = this.getJobsDir(skillId);
|
|
184
|
+
if (fs.existsSync(jobsDir)) {
|
|
185
|
+
const entries = fs.readdirSync(jobsDir, { withFileTypes: true });
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (entry.isDirectory()) {
|
|
188
|
+
const job = this.getJob(skillId, entry.name);
|
|
189
|
+
if (job) {
|
|
190
|
+
jobs.push(job);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const activeJobId = this.getActiveJobId(skillId);
|
|
196
|
+
if (!jobs.some(j => j.id === activeJobId)) {
|
|
197
|
+
const activeJob = this.getJob(skillId, activeJobId);
|
|
198
|
+
if (activeJob) {
|
|
199
|
+
jobs.push(activeJob);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Check for legacy root event store if no jobs found
|
|
203
|
+
if (jobs.length === 0) {
|
|
204
|
+
const legacyEventStore = path.join(this.getSkillDir(skillId), 'events.jsonl');
|
|
205
|
+
if (fs.existsSync(legacyEventStore)) {
|
|
206
|
+
const defaultJob = this.getJob(skillId, DEFAULT_JOB_ID);
|
|
207
|
+
if (defaultJob)
|
|
208
|
+
jobs.push(defaultJob);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return jobs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -30,7 +30,10 @@ export declare class ProjectionEngine {
|
|
|
30
30
|
private projections;
|
|
31
31
|
private compiledTemplates;
|
|
32
32
|
private eventCaches;
|
|
33
|
-
|
|
33
|
+
private jobId?;
|
|
34
|
+
private isActiveJob;
|
|
35
|
+
constructor(skillDir: string, projections?: DeliverableProjection[], workspaceDir?: string, jobId?: string, isActiveJob?: boolean);
|
|
36
|
+
setJob(jobId?: string, isActiveJob?: boolean): void;
|
|
34
37
|
private resolveOutputPath;
|
|
35
38
|
private getProjectionEvents;
|
|
36
39
|
private matchesTrigger;
|
|
@@ -11,13 +11,21 @@ export class ProjectionEngine {
|
|
|
11
11
|
projections;
|
|
12
12
|
compiledTemplates = new Map();
|
|
13
13
|
eventCaches = new WeakMap();
|
|
14
|
-
|
|
14
|
+
jobId;
|
|
15
|
+
isActiveJob;
|
|
16
|
+
constructor(skillDir, projections = [], workspaceDir = process.cwd(), jobId, isActiveJob = true) {
|
|
15
17
|
this.skillDir = skillDir;
|
|
16
18
|
this.workspaceDir = path.resolve(workspaceDir);
|
|
17
19
|
this.projections = projections;
|
|
20
|
+
this.jobId = jobId;
|
|
21
|
+
this.isActiveJob = isActiveJob;
|
|
18
22
|
this.registerHelpers();
|
|
19
23
|
this.compileTemplates();
|
|
20
24
|
}
|
|
25
|
+
setJob(jobId, isActiveJob = true) {
|
|
26
|
+
this.jobId = jobId;
|
|
27
|
+
this.isActiveJob = isActiveJob;
|
|
28
|
+
}
|
|
21
29
|
resolveOutputPath(output) {
|
|
22
30
|
if (path.isAbsolute(output)) {
|
|
23
31
|
throw new Error('Projection output must be relative to the workspace.');
|
|
@@ -138,13 +146,27 @@ export class ProjectionEngine {
|
|
|
138
146
|
}
|
|
139
147
|
if (templateFn) {
|
|
140
148
|
const outputContent = templateFn(projContext);
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
const canonicalOutputPath = this.resolveOutputPath(proj.output);
|
|
150
|
+
const canonicalDir = path.dirname(canonicalOutputPath);
|
|
151
|
+
const fileName = path.basename(canonicalOutputPath);
|
|
152
|
+
// 1. If jobId is present, write to historical job archive:
|
|
153
|
+
if (this.jobId) {
|
|
154
|
+
const archiveDir = path.join(canonicalDir, 'jobs', this.jobId);
|
|
155
|
+
if (!fs.existsSync(archiveDir)) {
|
|
156
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
157
|
+
}
|
|
158
|
+
const archivePath = path.join(archiveDir, fileName);
|
|
159
|
+
fs.writeFileSync(archivePath, outputContent, 'utf8');
|
|
160
|
+
writtenFiles.push(archivePath);
|
|
161
|
+
}
|
|
162
|
+
// 2. If active job or no jobId (legacy mode), mirror to canonical root:
|
|
163
|
+
if (this.isActiveJob || !this.jobId) {
|
|
164
|
+
if (!fs.existsSync(canonicalDir)) {
|
|
165
|
+
fs.mkdirSync(canonicalDir, { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
fs.writeFileSync(canonicalOutputPath, outputContent, 'utf8');
|
|
168
|
+
writtenFiles.push(canonicalOutputPath);
|
|
145
169
|
}
|
|
146
|
-
fs.writeFileSync(outputPath, outputContent, 'utf8');
|
|
147
|
-
writtenFiles.push(outputPath);
|
|
148
170
|
eventStore.saveProjectionWatermark(proj.template, eventStore.getLatestSequence(), `${this.skillDir}:${proj.template}`);
|
|
149
171
|
}
|
|
150
172
|
}
|