@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/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
  /**
@@ -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.db.close();
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: options.runId || options.run_id || createSortableId(),
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
- const runScopedDir = options.runId
355
- ? path.join(scopeDir, options.runId)
356
- : scopeDir;
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
  }
@@ -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
- runId: options.runId,
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
- constructor(skillDir: string, projections?: DeliverableProjection[], workspaceDir?: string);
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
- constructor(skillDir, projections = [], workspaceDir = process.cwd()) {
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 outputPath = this.resolveOutputPath(proj.output);
142
- const outputDir = path.dirname(outputPath);
143
- if (!fs.existsSync(outputDir)) {
144
- fs.mkdirSync(outputDir, { recursive: true });
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
  }