@portel/photon-core 2.11.0 → 2.12.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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Runtime Scheduling System
3
+ *
4
+ * Programmatic scheduling for photons — create, pause, resume, and cancel
5
+ * scheduled tasks at runtime. Available as `this.schedule` on Photon.
6
+ *
7
+ * Complements static `@scheduled`/`@cron` JSDoc tags with dynamic scheduling.
8
+ * Schedules persist to disk; the daemon reads and executes them.
9
+ *
10
+ * Storage: ~/.photon/schedules/{photonId}/{taskId}.json
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * export default class Cleanup extends Photon {
15
+ * async setup() {
16
+ * await this.schedule.create({
17
+ * name: 'nightly-cleanup',
18
+ * schedule: '0 0 * * *',
19
+ * method: 'purge',
20
+ * params: { olderThan: 30 },
21
+ * });
22
+ * }
23
+ *
24
+ * async purge({ olderThan }: { olderThan: number }) {
25
+ * // ... cleanup logic
26
+ * }
27
+ * }
28
+ * ```
29
+ */
30
+
31
+ import * as fs from 'fs/promises';
32
+ import * as path from 'path';
33
+ import * as os from 'os';
34
+ import { randomUUID } from 'crypto';
35
+
36
+ // ── Types ──────────────────────────────────────────────────────────────
37
+
38
+ export type ScheduleStatus = 'active' | 'paused' | 'completed' | 'error';
39
+
40
+ export interface ScheduledTask {
41
+ /** Unique task ID */
42
+ id: string;
43
+ /** Human-readable name */
44
+ name: string;
45
+ /** Optional description */
46
+ description?: string;
47
+ /** Cron expression (5-field: minute hour day month weekday) */
48
+ cron: string;
49
+ /** Method name on this photon to call */
50
+ method: string;
51
+ /** Parameters to pass to the method */
52
+ params: Record<string, any>;
53
+ /** Execute once then mark completed */
54
+ fireOnce: boolean;
55
+ /** Maximum number of executions (0 = unlimited) */
56
+ maxExecutions: number;
57
+ /** Current status */
58
+ status: ScheduleStatus;
59
+ /** ISO timestamp of creation */
60
+ createdAt: string;
61
+ /** ISO timestamp of last execution */
62
+ lastExecutionAt?: string;
63
+ /** Total execution count */
64
+ executionCount: number;
65
+ /** Error message if status is 'error' */
66
+ errorMessage?: string;
67
+ /** Photon that owns this task */
68
+ photonId: string;
69
+ }
70
+
71
+ export interface CreateScheduleOptions {
72
+ /** Human-readable name (must be unique per photon) */
73
+ name: string;
74
+ /** Cron expression (5-field) or shorthand: '@hourly', '@daily', '@weekly', '@monthly' */
75
+ schedule: string;
76
+ /** Method name on this photon to invoke */
77
+ method: string;
78
+ /** Parameters to pass to the method */
79
+ params?: Record<string, any>;
80
+ /** Optional description */
81
+ description?: string;
82
+ /** Execute once then auto-complete (default: false) */
83
+ fireOnce?: boolean;
84
+ /** Maximum executions before auto-complete (0 = unlimited, default: 0) */
85
+ maxExecutions?: number;
86
+ }
87
+
88
+ export interface UpdateScheduleOptions {
89
+ /** New cron schedule */
90
+ schedule?: string;
91
+ /** New method name */
92
+ method?: string;
93
+ /** New parameters */
94
+ params?: Record<string, any>;
95
+ /** New description */
96
+ description?: string;
97
+ /** Update fire-once flag */
98
+ fireOnce?: boolean;
99
+ /** Update max executions */
100
+ maxExecutions?: number;
101
+ }
102
+
103
+ // ── Cron Shorthands ────────────────────────────────────────────────────
104
+
105
+ const CRON_SHORTHANDS: Record<string, string> = {
106
+ '@yearly': '0 0 1 1 *',
107
+ '@annually': '0 0 1 1 *',
108
+ '@monthly': '0 0 1 * *',
109
+ '@weekly': '0 0 * * 0',
110
+ '@daily': '0 0 * * *',
111
+ '@midnight': '0 0 * * *',
112
+ '@hourly': '0 * * * *',
113
+ };
114
+
115
+ /**
116
+ * Resolve cron shorthands and validate basic format.
117
+ * Returns the resolved 5-field cron expression.
118
+ */
119
+ function resolveCron(schedule: string): string {
120
+ const trimmed = schedule.trim();
121
+
122
+ // Check shorthands
123
+ const shorthand = CRON_SHORTHANDS[trimmed.toLowerCase()];
124
+ if (shorthand) return shorthand;
125
+
126
+ // Validate 5-field cron format
127
+ const fields = trimmed.split(/\s+/);
128
+ if (fields.length !== 5) {
129
+ throw new Error(
130
+ `Invalid cron expression: '${schedule}'. Expected 5 fields (minute hour day month weekday) or a shorthand (@hourly, @daily, @weekly, @monthly, @yearly).`
131
+ );
132
+ }
133
+
134
+ return trimmed;
135
+ }
136
+
137
+ // ── Storage Helpers ────────────────────────────────────────────────────
138
+
139
+ function getSchedulesDir(): string {
140
+ return process.env.PHOTON_SCHEDULES_DIR || path.join(os.homedir(), '.photon', 'schedules');
141
+ }
142
+
143
+ function photonScheduleDir(photonId: string): string {
144
+ const safeName = photonId.replace(/[^a-zA-Z0-9_-]/g, '_');
145
+ return path.join(getSchedulesDir(), safeName);
146
+ }
147
+
148
+ function taskPath(photonId: string, taskId: string): string {
149
+ return path.join(photonScheduleDir(photonId), `${taskId}.json`);
150
+ }
151
+
152
+ async function ensureDir(dir: string): Promise<void> {
153
+ try {
154
+ await fs.mkdir(dir, { recursive: true });
155
+ } catch (err: any) {
156
+ if (err.code !== 'EEXIST') throw err;
157
+ }
158
+ }
159
+
160
+ // ── Schedule Provider ──────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Runtime Schedule Provider
164
+ *
165
+ * Provides CRUD operations for scheduled tasks.
166
+ * Tasks are persisted as JSON files that the daemon watches and executes.
167
+ */
168
+ export class ScheduleProvider {
169
+ private _photonId: string;
170
+
171
+ constructor(photonId: string) {
172
+ this._photonId = photonId;
173
+ }
174
+
175
+ /**
176
+ * Create a new scheduled task
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * await this.schedule.create({
181
+ * name: 'daily-report',
182
+ * schedule: '0 9 * * *',
183
+ * method: 'generate',
184
+ * params: { format: 'pdf' },
185
+ * });
186
+ * ```
187
+ */
188
+ async create(options: CreateScheduleOptions): Promise<ScheduledTask> {
189
+ const cron = resolveCron(options.schedule);
190
+
191
+ // Check for duplicate name
192
+ const existing = await this.getByName(options.name);
193
+ if (existing) {
194
+ throw new Error(`Schedule '${options.name}' already exists (id: ${existing.id}). Use update() to modify it.`);
195
+ }
196
+
197
+ const task: ScheduledTask = {
198
+ id: randomUUID(),
199
+ name: options.name,
200
+ description: options.description,
201
+ cron,
202
+ method: options.method,
203
+ params: options.params || {},
204
+ fireOnce: options.fireOnce ?? false,
205
+ maxExecutions: options.maxExecutions ?? 0,
206
+ status: 'active',
207
+ createdAt: new Date().toISOString(),
208
+ executionCount: 0,
209
+ photonId: this._photonId,
210
+ };
211
+
212
+ await this._save(task);
213
+ return task;
214
+ }
215
+
216
+ /**
217
+ * Get a scheduled task by ID
218
+ */
219
+ async get(taskId: string): Promise<ScheduledTask | null> {
220
+ try {
221
+ const content = await fs.readFile(taskPath(this._photonId, taskId), 'utf-8');
222
+ return JSON.parse(content) as ScheduledTask;
223
+ } catch (err: any) {
224
+ if (err.code === 'ENOENT') return null;
225
+ throw err;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Get a scheduled task by name
231
+ */
232
+ async getByName(name: string): Promise<ScheduledTask | null> {
233
+ const tasks = await this.list();
234
+ return tasks.find(t => t.name === name) || null;
235
+ }
236
+
237
+ /**
238
+ * List all scheduled tasks, optionally filtered by status
239
+ */
240
+ async list(status?: ScheduleStatus): Promise<ScheduledTask[]> {
241
+ const dir = photonScheduleDir(this._photonId);
242
+ let files: string[];
243
+ try {
244
+ files = await fs.readdir(dir);
245
+ } catch (err: any) {
246
+ if (err.code === 'ENOENT') return [];
247
+ throw err;
248
+ }
249
+
250
+ const tasks: ScheduledTask[] = [];
251
+ for (const file of files) {
252
+ if (!file.endsWith('.json')) continue;
253
+ try {
254
+ const content = await fs.readFile(path.join(dir, file), 'utf-8');
255
+ const task = JSON.parse(content) as ScheduledTask;
256
+ if (!status || task.status === status) {
257
+ tasks.push(task);
258
+ }
259
+ } catch {
260
+ // Skip corrupt files
261
+ }
262
+ }
263
+
264
+ return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
265
+ }
266
+
267
+ /**
268
+ * Update an existing scheduled task
269
+ */
270
+ async update(taskId: string, updates: UpdateScheduleOptions): Promise<ScheduledTask> {
271
+ const task = await this.get(taskId);
272
+ if (!task) {
273
+ throw new Error(`Schedule not found: ${taskId}`);
274
+ }
275
+
276
+ if (updates.schedule !== undefined) {
277
+ task.cron = resolveCron(updates.schedule);
278
+ }
279
+ if (updates.method !== undefined) task.method = updates.method;
280
+ if (updates.params !== undefined) task.params = updates.params;
281
+ if (updates.description !== undefined) task.description = updates.description;
282
+ if (updates.fireOnce !== undefined) task.fireOnce = updates.fireOnce;
283
+ if (updates.maxExecutions !== undefined) task.maxExecutions = updates.maxExecutions;
284
+
285
+ await this._save(task);
286
+ return task;
287
+ }
288
+
289
+ /**
290
+ * Pause a scheduled task (stops execution until resumed)
291
+ */
292
+ async pause(taskId: string): Promise<ScheduledTask> {
293
+ const task = await this.get(taskId);
294
+ if (!task) throw new Error(`Schedule not found: ${taskId}`);
295
+ if (task.status !== 'active') {
296
+ throw new Error(`Cannot pause task with status '${task.status}'. Only active tasks can be paused.`);
297
+ }
298
+ task.status = 'paused';
299
+ await this._save(task);
300
+ return task;
301
+ }
302
+
303
+ /**
304
+ * Resume a paused scheduled task
305
+ */
306
+ async resume(taskId: string): Promise<ScheduledTask> {
307
+ const task = await this.get(taskId);
308
+ if (!task) throw new Error(`Schedule not found: ${taskId}`);
309
+ if (task.status !== 'paused') {
310
+ throw new Error(`Cannot resume task with status '${task.status}'. Only paused tasks can be resumed.`);
311
+ }
312
+ task.status = 'active';
313
+ await this._save(task);
314
+ return task;
315
+ }
316
+
317
+ /**
318
+ * Cancel (delete) a scheduled task
319
+ */
320
+ async cancel(taskId: string): Promise<boolean> {
321
+ try {
322
+ await fs.unlink(taskPath(this._photonId, taskId));
323
+ return true;
324
+ } catch (err: any) {
325
+ if (err.code === 'ENOENT') return false;
326
+ throw err;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Cancel a scheduled task by name
332
+ */
333
+ async cancelByName(name: string): Promise<boolean> {
334
+ const task = await this.getByName(name);
335
+ if (!task) return false;
336
+ return this.cancel(task.id);
337
+ }
338
+
339
+ /**
340
+ * Check if a schedule with the given name exists
341
+ */
342
+ async has(name: string): Promise<boolean> {
343
+ const task = await this.getByName(name);
344
+ return task !== null;
345
+ }
346
+
347
+ /**
348
+ * Cancel all scheduled tasks for this photon
349
+ */
350
+ async cancelAll(): Promise<number> {
351
+ const tasks = await this.list();
352
+ let count = 0;
353
+ for (const task of tasks) {
354
+ if (await this.cancel(task.id)) count++;
355
+ }
356
+ return count;
357
+ }
358
+
359
+ /** @internal */
360
+ private async _save(task: ScheduledTask): Promise<void> {
361
+ const dir = photonScheduleDir(this._photonId);
362
+ await ensureDir(dir);
363
+ await fs.writeFile(taskPath(this._photonId, task.id), JSON.stringify(task, null, 2));
364
+ }
365
+ }
@@ -257,7 +257,7 @@ export class SchemaExtractor {
257
257
  const openWorldHint = this.extractOpenWorldHint(jsdoc);
258
258
  const audience = this.extractAudience(jsdoc);
259
259
  const contentPriority = this.extractContentPriority(jsdoc);
260
- const outputSchema = this.extractOutputSchema(jsdoc);
260
+ const outputSchema = this.inferOutputSchemaFromReturnType(member, sourceFile);
261
261
 
262
262
  // Daemon features
263
263
  const webhook = this.extractWebhook(jsdoc, methodName);
@@ -653,6 +653,12 @@ export class SchemaExtractor {
653
653
  properties[propName] = { type: 'object' };
654
654
  }
655
655
 
656
+ // Extract JSDoc description from property (e.g., /** Task ID */ id: string)
657
+ const jsDocComment = this.getPropertyJsDoc(member, sourceFile);
658
+ if (jsDocComment) {
659
+ properties[propName].description = jsDocComment;
660
+ }
661
+
656
662
  // Add readonly from TypeScript (JSDoc can override)
657
663
  if (isReadonly) {
658
664
  properties[propName]._tsReadOnly = true;
@@ -664,6 +670,32 @@ export class SchemaExtractor {
664
670
  return { properties, required };
665
671
  }
666
672
 
673
+ /**
674
+ * Extract JSDoc description from a property signature.
675
+ * Handles both /** comment * / style and // comment style.
676
+ */
677
+ private getPropertyJsDoc(member: ts.PropertySignature, sourceFile: ts.SourceFile): string | undefined {
678
+ // Check for JSDoc comments attached to the node
679
+ const jsDocNodes = (member as any).jsDoc;
680
+ if (jsDocNodes && jsDocNodes.length > 0) {
681
+ const comment = jsDocNodes[0].comment;
682
+ if (typeof comment === 'string') return comment.trim();
683
+ }
684
+
685
+ // Fallback: look for leading comment in source text
686
+ const fullText = sourceFile.getFullText();
687
+ const start = member.getFullStart();
688
+ const leading = fullText.substring(start, member.getStart(sourceFile)).trim();
689
+
690
+ // Match /** ... */ or /* ... */
691
+ const blockMatch = leading.match(/\/\*\*?\s*([\s\S]*?)\s*\*\//);
692
+ if (blockMatch) {
693
+ return blockMatch[1].replace(/\s*\*\s*/g, ' ').trim();
694
+ }
695
+
696
+ return undefined;
697
+ }
698
+
667
699
  /**
668
700
  * Convert TypeScript type node to JSON schema
669
701
  */
@@ -793,6 +825,15 @@ export class SchemaExtractor {
793
825
  return;
794
826
  }
795
827
 
828
+ // Also resolve interface declarations → synthesize a TypeLiteral
829
+ if (ts.isInterfaceDeclaration(node) && node.name.text === typeName) {
830
+ // Create a synthetic TypeLiteralNode from interface members
831
+ resolved = ts.factory.createTypeLiteralNode(
832
+ node.members.filter(ts.isPropertySignature) as ts.PropertySignature[]
833
+ );
834
+ return;
835
+ }
836
+
796
837
  ts.forEachChild(node, visit);
797
838
  };
798
839
 
@@ -1859,31 +1900,38 @@ export class SchemaExtractor {
1859
1900
  }
1860
1901
 
1861
1902
  /**
1862
- * Extract structured output schema from @returns.field {type} tags
1863
- * @returns.id {string} Task ID
1864
- * @returns.title {string} Task title
1865
- * @returns.done {boolean} Completion status
1866
- * → { type: 'object', properties: { id: { type: 'string', description: 'Task ID' }, ... } }
1903
+ * Infer output schema from TypeScript return type annotation.
1904
+ * Unwraps Promise<T> and converts T to JSON Schema.
1905
+ * Property descriptions come from JSDoc on the type/interface properties.
1906
+ * Only produces a schema for object return types (not primitives/arrays).
1867
1907
  */
1868
- private extractOutputSchema(jsdocContent: string): { type: 'object'; properties: Record<string, any>; required?: string[] } | undefined {
1869
- const fieldRegex = /@returns\.(\w+)\s+\{(\w+)\}\s*([^\n*]*)/g;
1870
- const properties: Record<string, any> = {};
1871
- let match: RegExpExecArray | null;
1872
- while ((match = fieldRegex.exec(jsdocContent)) !== null) {
1873
- const [, field, type, desc] = match;
1874
- const jsonType = type.toLowerCase() === 'boolean' ? 'boolean'
1875
- : type.toLowerCase() === 'number' || type.toLowerCase() === 'integer' ? 'number'
1876
- : type.toLowerCase() === 'array' ? 'array'
1877
- : type.toLowerCase() === 'object' ? 'object'
1878
- : 'string';
1879
- properties[field] = { type: jsonType };
1880
- const trimmedDesc = desc.replace(/\s*\*\s*/g, ' ').trim();
1881
- if (trimmedDesc) {
1882
- properties[field].description = trimmedDesc;
1883
- }
1884
- }
1885
- if (Object.keys(properties).length === 0) return undefined;
1886
- return { type: 'object', properties };
1908
+ private inferOutputSchemaFromReturnType(method: ts.MethodDeclaration, sourceFile: ts.SourceFile): { type: 'object'; properties: Record<string, any>; required?: string[] } | undefined {
1909
+ const returnType = method.type;
1910
+ if (!returnType) return undefined;
1911
+
1912
+ // Unwrap Promise<T> T
1913
+ let innerType: ts.TypeNode = returnType;
1914
+ if (ts.isTypeReferenceNode(returnType)) {
1915
+ const typeName = returnType.typeName.getText(sourceFile);
1916
+ if (typeName === 'Promise' && returnType.typeArguments?.length) {
1917
+ innerType = returnType.typeArguments[0];
1918
+ }
1919
+ }
1920
+
1921
+ // Convert to JSON Schema
1922
+ const schema = this.typeNodeToSchema(innerType, sourceFile);
1923
+
1924
+ // Only produce outputSchema for object types with properties
1925
+ if (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length > 0) {
1926
+ const result: { type: 'object'; properties: Record<string, any>; required?: string[] } = {
1927
+ type: 'object',
1928
+ properties: schema.properties,
1929
+ };
1930
+ if (schema.required?.length) result.required = schema.required;
1931
+ return result;
1932
+ }
1933
+
1934
+ return undefined;
1887
1935
  }
1888
1936
 
1889
1937
  /**
package/src/types.ts CHANGED
@@ -28,6 +28,12 @@ export interface PhotonTool {
28
28
  outputFormat?: OutputFormat;
29
29
  /** When true, method uses individual params instead of a single params object */
30
30
  simpleParams?: boolean;
31
+ /**
32
+ * Icon image entries from @icon (file path) or @icons tags.
33
+ * Raw paths — resolved to data URIs by the runtime.
34
+ * Maps to MCP Tool.icons[]
35
+ */
36
+ iconImages?: Array<{ path: string; sizes?: string; theme?: string }>;
31
37
  }
32
38
 
33
39
  /**