opencode-jobs 0.1.0 → 0.1.1
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 +5 -0
- package/dist/cli.js +187 -33
- package/dist/index.js +137 -110
- package/dist/json.d.ts +0 -3
- package/dist/runs.d.ts +15 -12
- package/package.json +5 -7
package/README.md
CHANGED
package/dist/cli.js
CHANGED
|
@@ -7,18 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
// src/install.ts
|
|
8
8
|
import { existsSync as existsSync2, readFileSync as readFileSync2, rmSync as rmSync2, rmdirSync, statSync } from "node:fs";
|
|
9
9
|
import path5 from "node:path";
|
|
10
|
-
|
|
11
|
-
// src/json.ts
|
|
12
|
-
function isRecord(value) {
|
|
13
|
-
return typeof value === "object" && value !== null;
|
|
14
|
-
}
|
|
15
|
-
function stringProperty(record, key) {
|
|
16
|
-
const value = record[key];
|
|
17
|
-
return typeof value === "string" ? value : undefined;
|
|
18
|
-
}
|
|
19
|
-
function errorMessage(error) {
|
|
20
|
-
return error instanceof Error ? error.message : String(error);
|
|
21
|
-
}
|
|
10
|
+
import { z as z3 } from "zod";
|
|
22
11
|
|
|
23
12
|
// src/paths.ts
|
|
24
13
|
import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -80,24 +69,193 @@ function deriveScopeId(workdir) {
|
|
|
80
69
|
// src/project.ts
|
|
81
70
|
import path4 from "node:path";
|
|
82
71
|
|
|
72
|
+
// src/job.ts
|
|
73
|
+
import { z } from "zod";
|
|
74
|
+
|
|
75
|
+
// src/cron.ts
|
|
76
|
+
var DOW_NAMES = {
|
|
77
|
+
sun: 0,
|
|
78
|
+
mon: 1,
|
|
79
|
+
tue: 2,
|
|
80
|
+
wed: 3,
|
|
81
|
+
thu: 4,
|
|
82
|
+
fri: 5,
|
|
83
|
+
sat: 6
|
|
84
|
+
};
|
|
85
|
+
var MONTH_NAMES = {
|
|
86
|
+
jan: 1,
|
|
87
|
+
feb: 2,
|
|
88
|
+
mar: 3,
|
|
89
|
+
apr: 4,
|
|
90
|
+
may: 5,
|
|
91
|
+
jun: 6,
|
|
92
|
+
jul: 7,
|
|
93
|
+
aug: 8,
|
|
94
|
+
sep: 9,
|
|
95
|
+
oct: 10,
|
|
96
|
+
nov: 11,
|
|
97
|
+
dec: 12
|
|
98
|
+
};
|
|
99
|
+
function parseBound(raw, field, min, max, names) {
|
|
100
|
+
const trimmed = raw.trim().toLowerCase();
|
|
101
|
+
const named = names?.[trimmed];
|
|
102
|
+
if (named !== undefined)
|
|
103
|
+
return named;
|
|
104
|
+
if (!/^\d+$/.test(trimmed))
|
|
105
|
+
throw new Error(`Invalid ${field} value "${raw}" in cron expression`);
|
|
106
|
+
const value = Number(trimmed);
|
|
107
|
+
if (value < min || value > max)
|
|
108
|
+
throw new Error(`${field} value ${String(value)} out of range ${String(min)}-${String(max)} in cron expression`);
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
function parseCronField(raw, field, min, max, names) {
|
|
112
|
+
if (raw.length === 0)
|
|
113
|
+
throw new Error(`Empty ${field} field in cron expression`);
|
|
114
|
+
const values = new Set;
|
|
115
|
+
for (const part of raw.split(",")) {
|
|
116
|
+
if (part.length === 0)
|
|
117
|
+
throw new Error(`Empty ${field} item in cron expression "${raw}"`);
|
|
118
|
+
const [rangePart = "", stepPart] = part.split("/", 2);
|
|
119
|
+
if (stepPart !== undefined && (!/^\d+$/.test(stepPart) || Number(stepPart) < 1)) {
|
|
120
|
+
throw new Error(`Invalid step "/${stepPart}" in ${field} field`);
|
|
121
|
+
}
|
|
122
|
+
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
123
|
+
let lo;
|
|
124
|
+
let hi;
|
|
125
|
+
if (rangePart === "*") {
|
|
126
|
+
lo = min;
|
|
127
|
+
hi = max;
|
|
128
|
+
} else if (rangePart.includes("-")) {
|
|
129
|
+
const pieces = rangePart.split("-");
|
|
130
|
+
if (pieces.length !== 2)
|
|
131
|
+
throw new Error(`Invalid range "${rangePart}" in ${field} field`);
|
|
132
|
+
const [loPart = "", hiPart = ""] = pieces;
|
|
133
|
+
lo = parseBound(loPart, field, min, max, names);
|
|
134
|
+
hi = parseBound(hiPart, field, min, max, names);
|
|
135
|
+
if (lo > hi)
|
|
136
|
+
throw new Error(`Descending range "${rangePart}" in ${field} field`);
|
|
137
|
+
} else {
|
|
138
|
+
lo = parseBound(rangePart, field, min, max, names);
|
|
139
|
+
hi = stepPart === undefined ? lo : max;
|
|
140
|
+
}
|
|
141
|
+
for (let v = lo;v <= hi; v += step) {
|
|
142
|
+
values.add(field === "dow" && v === 7 ? 0 : v);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...values].toSorted((a, b) => a - b);
|
|
146
|
+
}
|
|
147
|
+
function parseCron(expression) {
|
|
148
|
+
const fields = expression.trim().split(/\s+/);
|
|
149
|
+
if (fields.length !== 5)
|
|
150
|
+
throw new Error(`Expected 5-field cron expression, got ${String(fields.length)} fields: "${expression}"`);
|
|
151
|
+
const [minute = "", hour = "", dom = "", month = "", dow = ""] = fields;
|
|
152
|
+
return {
|
|
153
|
+
minute: parseCronField(minute, "minute", 0, 59),
|
|
154
|
+
hour: parseCronField(hour, "hour", 0, 23),
|
|
155
|
+
dom: parseCronField(dom, "day of month", 1, 31),
|
|
156
|
+
month: parseCronField(month, "month", 1, 12, MONTH_NAMES),
|
|
157
|
+
dow: parseCronField(dow, "dow", 0, 7, DOW_NAMES)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/json.ts
|
|
162
|
+
function errorMessage(error) {
|
|
163
|
+
return error instanceof Error ? error.message : String(error);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/job.ts
|
|
167
|
+
var SESSION_MODES = [
|
|
168
|
+
"new",
|
|
169
|
+
"persist",
|
|
170
|
+
"compact",
|
|
171
|
+
"compact+last"
|
|
172
|
+
];
|
|
173
|
+
var nonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty string");
|
|
174
|
+
var runSpecSchema = z.strictObject({
|
|
175
|
+
prompt: nonEmptyStringSchema.optional(),
|
|
176
|
+
command: nonEmptyStringSchema.optional(),
|
|
177
|
+
arguments: z.string().optional(),
|
|
178
|
+
agent: z.string().optional(),
|
|
179
|
+
model: z.string().optional()
|
|
180
|
+
}).superRefine((run, context) => {
|
|
181
|
+
if (run.prompt === undefined === (run.command === undefined)) {
|
|
182
|
+
context.addIssue({
|
|
183
|
+
code: "custom",
|
|
184
|
+
message: 'must set exactly one of "prompt" (natural language) or "command" (custom command name)'
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (run.prompt !== undefined && run.arguments !== undefined) {
|
|
188
|
+
context.addIssue({
|
|
189
|
+
code: "custom",
|
|
190
|
+
path: ["arguments"],
|
|
191
|
+
message: "only applies to command jobs"
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}).transform((run) => {
|
|
195
|
+
if (run.command !== undefined) {
|
|
196
|
+
return {
|
|
197
|
+
command: run.command,
|
|
198
|
+
...run.arguments !== undefined && { arguments: run.arguments },
|
|
199
|
+
...run.agent !== undefined && { agent: run.agent },
|
|
200
|
+
...run.model !== undefined && { model: run.model }
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
prompt: run.prompt ?? "",
|
|
205
|
+
...run.agent !== undefined && { agent: run.agent },
|
|
206
|
+
...run.model !== undefined && { model: run.model }
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
var sessionSchema = z.enum(SESSION_MODES, {
|
|
210
|
+
error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
|
|
211
|
+
});
|
|
212
|
+
var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
|
|
213
|
+
var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
|
|
214
|
+
var cronSchema = z.string().superRefine((schedule, context) => {
|
|
215
|
+
try {
|
|
216
|
+
parseCron(schedule);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
context.addIssue({ code: "custom", message: errorMessage(error) });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
var jobFileSchema = z.strictObject({
|
|
222
|
+
slug: z.string().optional(),
|
|
223
|
+
name: nonEmptyStringSchema,
|
|
224
|
+
schedule: cronSchema,
|
|
225
|
+
run: runSpecSchema,
|
|
226
|
+
session: sessionSchema.default("new"),
|
|
227
|
+
guard: guardSchema.optional(),
|
|
228
|
+
timeoutSeconds: timeoutSchema.optional(),
|
|
229
|
+
createdAt: z.string().optional(),
|
|
230
|
+
updatedAt: z.string().optional()
|
|
231
|
+
});
|
|
232
|
+
|
|
83
233
|
// src/registry.ts
|
|
84
234
|
import { readFileSync } from "node:fs";
|
|
85
235
|
import path2 from "node:path";
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
236
|
+
import { z as z2 } from "zod";
|
|
237
|
+
var registryEntrySchema = z2.looseObject({
|
|
238
|
+
scopeId: z2.string(),
|
|
239
|
+
workdir: z2.string(),
|
|
240
|
+
enabledAt: z2.string(),
|
|
241
|
+
updatedAt: z2.string(),
|
|
242
|
+
jobs: z2.array(z2.string())
|
|
243
|
+
});
|
|
244
|
+
var registryFileSchema = z2.object({
|
|
245
|
+
version: z2.literal(1),
|
|
246
|
+
projects: z2.record(z2.string(), z2.unknown())
|
|
247
|
+
});
|
|
91
248
|
function loadRegistry() {
|
|
92
249
|
try {
|
|
93
250
|
const parsed = JSON.parse(readFileSync(registryPath(), "utf8"));
|
|
94
|
-
|
|
251
|
+
const result = registryFileSchema.safeParse(parsed);
|
|
252
|
+
if (!result.success)
|
|
95
253
|
return { version: 1, projects: {} };
|
|
96
|
-
}
|
|
97
254
|
const projects = {};
|
|
98
|
-
for (const [key, value] of Object.entries(
|
|
99
|
-
|
|
100
|
-
|
|
255
|
+
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
256
|
+
const entry = registryEntrySchema.safeParse(value);
|
|
257
|
+
if (entry.success)
|
|
258
|
+
projects[key] = entry.data;
|
|
101
259
|
}
|
|
102
260
|
return { version: 1, projects };
|
|
103
261
|
} catch {
|
|
@@ -169,6 +327,9 @@ function disableProject(workdir) {
|
|
|
169
327
|
var PACKAGE_NAME = "opencode-jobs";
|
|
170
328
|
var SKILL_NAME = "opencode-jobs";
|
|
171
329
|
var CONFIG_SCHEMA = "https://opencode.ai/config.json";
|
|
330
|
+
var configSchema = z3.looseObject({
|
|
331
|
+
plugin: z3.array(z3.unknown()).optional()
|
|
332
|
+
});
|
|
172
333
|
var CONFIG_LOCATIONS = [
|
|
173
334
|
"opencode.json",
|
|
174
335
|
"opencode.jsonc",
|
|
@@ -186,7 +347,8 @@ function existingConfigPath(projectDirectory) {
|
|
|
186
347
|
function readConfig(configPath) {
|
|
187
348
|
try {
|
|
188
349
|
const value = JSON.parse(readFileSync2(configPath, "utf8"));
|
|
189
|
-
|
|
350
|
+
const result = configSchema.safeParse(value);
|
|
351
|
+
return result.success ? result.data : undefined;
|
|
190
352
|
} catch {
|
|
191
353
|
return;
|
|
192
354
|
}
|
|
@@ -213,11 +375,7 @@ function installPluginConfig(projectDirectory) {
|
|
|
213
375
|
const config = readConfig(configPath);
|
|
214
376
|
if (config === undefined)
|
|
215
377
|
return { status: "manual", configPath };
|
|
216
|
-
const
|
|
217
|
-
if (plugins !== undefined && !Array.isArray(plugins)) {
|
|
218
|
-
return { status: "manual", configPath };
|
|
219
|
-
}
|
|
220
|
-
const entries = Array.isArray(plugins) ? plugins : [];
|
|
378
|
+
const entries = config.plugin ?? [];
|
|
221
379
|
if (entries.some((entry) => isPackageReference(entry))) {
|
|
222
380
|
return { status: "present", configPath };
|
|
223
381
|
}
|
|
@@ -260,11 +418,7 @@ function uninstallPluginConfig(projectDirectory) {
|
|
|
260
418
|
const config = readConfig(configPath);
|
|
261
419
|
if (config === undefined)
|
|
262
420
|
return { status: "manual", configPath };
|
|
263
|
-
const
|
|
264
|
-
if (plugins !== undefined && !Array.isArray(plugins)) {
|
|
265
|
-
return { status: "manual", configPath };
|
|
266
|
-
}
|
|
267
|
-
const entries = Array.isArray(plugins) ? plugins : [];
|
|
421
|
+
const entries = config.plugin ?? [];
|
|
268
422
|
const remaining = entries.filter((entry) => !isPackageReference(entry));
|
|
269
423
|
if (remaining.length === entries.length) {
|
|
270
424
|
return { status: "absent", configPath };
|
package/dist/index.js
CHANGED
|
@@ -227,19 +227,9 @@ function deriveScopeId(workdir) {
|
|
|
227
227
|
// src/job.ts
|
|
228
228
|
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
229
229
|
import path2 from "path";
|
|
230
|
+
import { z } from "zod";
|
|
230
231
|
|
|
231
232
|
// src/json.ts
|
|
232
|
-
function isRecord(value) {
|
|
233
|
-
return typeof value === "object" && value !== null;
|
|
234
|
-
}
|
|
235
|
-
function stringProperty(record, key) {
|
|
236
|
-
const value = record[key];
|
|
237
|
-
return typeof value === "string" ? value : undefined;
|
|
238
|
-
}
|
|
239
|
-
function numberProperty(record, key) {
|
|
240
|
-
const value = record[key];
|
|
241
|
-
return typeof value === "number" ? value : undefined;
|
|
242
|
-
}
|
|
243
233
|
function errorMessage(error) {
|
|
244
234
|
return error instanceof Error ? error.message : String(error);
|
|
245
235
|
}
|
|
@@ -251,69 +241,110 @@ var SESSION_MODES = [
|
|
|
251
241
|
"compact",
|
|
252
242
|
"compact+last"
|
|
253
243
|
];
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (prompt
|
|
263
|
-
|
|
244
|
+
var nonEmptyStringSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty string");
|
|
245
|
+
var runSpecSchema = z.strictObject({
|
|
246
|
+
prompt: nonEmptyStringSchema.optional(),
|
|
247
|
+
command: nonEmptyStringSchema.optional(),
|
|
248
|
+
arguments: z.string().optional(),
|
|
249
|
+
agent: z.string().optional(),
|
|
250
|
+
model: z.string().optional()
|
|
251
|
+
}).superRefine((run, context) => {
|
|
252
|
+
if (run.prompt === undefined === (run.command === undefined)) {
|
|
253
|
+
context.addIssue({
|
|
254
|
+
code: "custom",
|
|
255
|
+
message: 'must set exactly one of "prompt" (natural language) or "command" (custom command name)'
|
|
256
|
+
});
|
|
264
257
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
258
|
+
if (run.prompt !== undefined && run.arguments !== undefined) {
|
|
259
|
+
context.addIssue({
|
|
260
|
+
code: "custom",
|
|
261
|
+
path: ["arguments"],
|
|
262
|
+
message: "only applies to command jobs"
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}).transform((run) => {
|
|
266
|
+
if (run.command !== undefined) {
|
|
269
267
|
return {
|
|
270
|
-
command,
|
|
271
|
-
...
|
|
272
|
-
...agent !== undefined && { agent },
|
|
273
|
-
...model !== undefined && { model }
|
|
268
|
+
command: run.command,
|
|
269
|
+
...run.arguments !== undefined && { arguments: run.arguments },
|
|
270
|
+
...run.agent !== undefined && { agent: run.agent },
|
|
271
|
+
...run.model !== undefined && { model: run.model }
|
|
274
272
|
};
|
|
275
273
|
}
|
|
276
|
-
if (commandArguments !== undefined) {
|
|
277
|
-
throw new Error(`${context}: "run.arguments" only applies to command jobs`);
|
|
278
|
-
}
|
|
279
274
|
return {
|
|
280
|
-
prompt: prompt ?? "",
|
|
281
|
-
...agent !== undefined && { agent },
|
|
282
|
-
...model !== undefined && { model }
|
|
275
|
+
prompt: run.prompt ?? "",
|
|
276
|
+
...run.agent !== undefined && { agent: run.agent },
|
|
277
|
+
...run.model !== undefined && { model: run.model }
|
|
283
278
|
};
|
|
279
|
+
});
|
|
280
|
+
var sessionSchema = z.enum(SESSION_MODES, {
|
|
281
|
+
error: `must be one of ${SESSION_MODES.map((mode) => `"${mode}"`).join(", ")}`
|
|
282
|
+
});
|
|
283
|
+
var guardSchema = z.string().refine((value) => value.trim().length > 0, "must be a non-empty shell command string");
|
|
284
|
+
var timeoutSchema = z.number().int().nonnegative("must be a non-negative integer");
|
|
285
|
+
var cronSchema = z.string().superRefine((schedule, context) => {
|
|
286
|
+
try {
|
|
287
|
+
parseCron(schedule);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
context.addIssue({ code: "custom", message: errorMessage(error) });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
var jobFileSchema = z.strictObject({
|
|
293
|
+
slug: z.string().optional(),
|
|
294
|
+
name: nonEmptyStringSchema,
|
|
295
|
+
schedule: cronSchema,
|
|
296
|
+
run: runSpecSchema,
|
|
297
|
+
session: sessionSchema.default("new"),
|
|
298
|
+
guard: guardSchema.optional(),
|
|
299
|
+
timeoutSeconds: timeoutSchema.optional(),
|
|
300
|
+
createdAt: z.string().optional(),
|
|
301
|
+
updatedAt: z.string().optional()
|
|
302
|
+
});
|
|
303
|
+
function formatValidationError(error) {
|
|
304
|
+
const issue = error.issues[0];
|
|
305
|
+
if (issue === undefined)
|
|
306
|
+
return "invalid job definition";
|
|
307
|
+
const field = issue.path.join(".");
|
|
308
|
+
return field.length === 0 ? issue.message : `"${field}": ${issue.message}`;
|
|
309
|
+
}
|
|
310
|
+
function parseWithContext(schema, value, context) {
|
|
311
|
+
const result = schema.safeParse(value);
|
|
312
|
+
if (!result.success) {
|
|
313
|
+
throw new Error(`${context}: ${formatValidationError(result.error)}`);
|
|
314
|
+
}
|
|
315
|
+
return result.data;
|
|
316
|
+
}
|
|
317
|
+
function validateRunSpec(run, context) {
|
|
318
|
+
return parseWithContext(runSpecSchema, run, context);
|
|
284
319
|
}
|
|
285
320
|
function validateSession(value, context) {
|
|
286
321
|
if (value === undefined)
|
|
287
322
|
return "new";
|
|
288
|
-
|
|
289
|
-
if (mode === undefined) {
|
|
290
|
-
throw new Error(`${context}: "session" must be one of ${SESSION_MODES.map((candidate) => `"${candidate}"`).join(", ")}`);
|
|
291
|
-
}
|
|
292
|
-
return mode;
|
|
323
|
+
return parseWithContext(sessionSchema, value, context);
|
|
293
324
|
}
|
|
294
325
|
function validateTimeout(value, context) {
|
|
295
326
|
if (value === undefined)
|
|
296
327
|
return;
|
|
297
|
-
|
|
298
|
-
throw new Error(`${context}: "timeoutSeconds" must be a non-negative integer`);
|
|
299
|
-
}
|
|
300
|
-
return value;
|
|
328
|
+
return parseWithContext(timeoutSchema, value, context);
|
|
301
329
|
}
|
|
302
330
|
function validateGuard(value, context) {
|
|
303
331
|
if (value === undefined)
|
|
304
332
|
return;
|
|
305
|
-
|
|
306
|
-
throw new Error(`${context}: "guard" must be a non-empty shell command string`);
|
|
307
|
-
}
|
|
308
|
-
return value;
|
|
333
|
+
return parseWithContext(guardSchema, value, context);
|
|
309
334
|
}
|
|
310
335
|
function loadJobFile(file, expectedSlug) {
|
|
311
|
-
const stem = path2.basename(file
|
|
336
|
+
const stem = path2.basename(file, ".json");
|
|
312
337
|
try {
|
|
313
338
|
const object = JSON.parse(readFileSync(file, "utf8"));
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
339
|
+
const result = jobFileSchema.safeParse(object);
|
|
340
|
+
if (!result.success) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: `${stem}.json: ${formatValidationError(result.error)}`
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
const definition = result.data;
|
|
347
|
+
const slug = definition.slug ?? stem;
|
|
317
348
|
if (slug !== stem)
|
|
318
349
|
return {
|
|
319
350
|
ok: false,
|
|
@@ -321,31 +352,21 @@ function loadJobFile(file, expectedSlug) {
|
|
|
321
352
|
};
|
|
322
353
|
if (expectedSlug !== undefined && slug !== expectedSlug)
|
|
323
354
|
return { ok: false, error: `${stem}.json: unexpected slug` };
|
|
324
|
-
const
|
|
325
|
-
if (name === undefined)
|
|
326
|
-
return { ok: false, error: `${stem}.json: "name" is required` };
|
|
327
|
-
const schedule = stringProperty(object, "schedule") ?? "";
|
|
328
|
-
try {
|
|
329
|
-
parseCron(schedule);
|
|
330
|
-
} catch (error) {
|
|
331
|
-
return { ok: false, error: `${stem}.json: ${errorMessage(error)}` };
|
|
332
|
-
}
|
|
333
|
-
const run = validateRunSpec(object.run, `${stem}.json`);
|
|
334
|
-
const session = validateSession(object.session, `${stem}.json`);
|
|
335
|
-
const guard = validateGuard(object.guard, `${stem}.json`);
|
|
336
|
-
const timeoutSeconds = validateTimeout(object.timeoutSeconds, `${stem}.json`);
|
|
355
|
+
const timestamp = nowIso();
|
|
337
356
|
return {
|
|
338
357
|
ok: true,
|
|
339
358
|
job: {
|
|
340
359
|
slug,
|
|
341
|
-
name,
|
|
342
|
-
schedule,
|
|
343
|
-
run,
|
|
344
|
-
...session !== "new" && { session },
|
|
345
|
-
...guard !== undefined && { guard },
|
|
346
|
-
...timeoutSeconds !== undefined && {
|
|
347
|
-
|
|
348
|
-
|
|
360
|
+
name: definition.name,
|
|
361
|
+
schedule: definition.schedule,
|
|
362
|
+
run: definition.run,
|
|
363
|
+
...definition.session !== "new" && { session: definition.session },
|
|
364
|
+
...definition.guard !== undefined && { guard: definition.guard },
|
|
365
|
+
...definition.timeoutSeconds !== undefined && {
|
|
366
|
+
timeoutSeconds: definition.timeoutSeconds
|
|
367
|
+
},
|
|
368
|
+
createdAt: definition.createdAt ?? timestamp,
|
|
369
|
+
updatedAt: definition.updatedAt ?? timestamp
|
|
349
370
|
}
|
|
350
371
|
};
|
|
351
372
|
} catch (error) {
|
|
@@ -381,21 +402,29 @@ function saveJob(workdir, job) {
|
|
|
381
402
|
// src/registry.ts
|
|
382
403
|
import { readFileSync as readFileSync2 } from "fs";
|
|
383
404
|
import path3 from "path";
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
405
|
+
import { z as z2 } from "zod";
|
|
406
|
+
var registryEntrySchema = z2.looseObject({
|
|
407
|
+
scopeId: z2.string(),
|
|
408
|
+
workdir: z2.string(),
|
|
409
|
+
enabledAt: z2.string(),
|
|
410
|
+
updatedAt: z2.string(),
|
|
411
|
+
jobs: z2.array(z2.string())
|
|
412
|
+
});
|
|
413
|
+
var registryFileSchema = z2.object({
|
|
414
|
+
version: z2.literal(1),
|
|
415
|
+
projects: z2.record(z2.string(), z2.unknown())
|
|
416
|
+
});
|
|
389
417
|
function loadRegistry() {
|
|
390
418
|
try {
|
|
391
419
|
const parsed = JSON.parse(readFileSync2(registryPath(), "utf8"));
|
|
392
|
-
|
|
420
|
+
const result = registryFileSchema.safeParse(parsed);
|
|
421
|
+
if (!result.success)
|
|
393
422
|
return { version: 1, projects: {} };
|
|
394
|
-
}
|
|
395
423
|
const projects = {};
|
|
396
|
-
for (const [key, value] of Object.entries(
|
|
397
|
-
|
|
398
|
-
|
|
424
|
+
for (const [key, value] of Object.entries(result.data.projects)) {
|
|
425
|
+
const entry = registryEntrySchema.safeParse(value);
|
|
426
|
+
if (entry.success)
|
|
427
|
+
projects[key] = entry.data;
|
|
399
428
|
}
|
|
400
429
|
return { version: 1, projects };
|
|
401
430
|
} catch {
|
|
@@ -412,30 +441,21 @@ function registryEntry(workdir) {
|
|
|
412
441
|
|
|
413
442
|
// src/runs.ts
|
|
414
443
|
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
...startedAt !== undefined && { startedAt },
|
|
431
|
-
...finishedAt !== undefined && { finishedAt },
|
|
432
|
-
...durationMs !== undefined && { durationMs },
|
|
433
|
-
...status !== undefined && { status },
|
|
434
|
-
...exitCode !== undefined && { exitCode },
|
|
435
|
-
...sessionId !== undefined && { sessionId },
|
|
436
|
-
...startedBy !== undefined && { startedBy }
|
|
437
|
-
};
|
|
438
|
-
}
|
|
444
|
+
import { z as z3 } from "zod";
|
|
445
|
+
var optionalString = z3.string().optional().catch(undefined);
|
|
446
|
+
var optionalNumber = z3.number().optional().catch(undefined);
|
|
447
|
+
var runRecordSchema = z3.object({
|
|
448
|
+
runId: optionalString,
|
|
449
|
+
slug: optionalString,
|
|
450
|
+
scopeId: optionalString,
|
|
451
|
+
startedAt: optionalNumber,
|
|
452
|
+
finishedAt: optionalNumber,
|
|
453
|
+
durationMs: optionalNumber,
|
|
454
|
+
status: optionalString,
|
|
455
|
+
exitCode: optionalNumber,
|
|
456
|
+
sessionId: optionalString,
|
|
457
|
+
startedBy: optionalString
|
|
458
|
+
});
|
|
439
459
|
function readRunRecords(scopeId, slug, limit) {
|
|
440
460
|
const file = runsFile(scopeId, slug);
|
|
441
461
|
if (!existsSync2(file))
|
|
@@ -447,8 +467,9 @@ function readRunRecords(scopeId, slug, limit) {
|
|
|
447
467
|
continue;
|
|
448
468
|
try {
|
|
449
469
|
const value = JSON.parse(line);
|
|
450
|
-
|
|
451
|
-
|
|
470
|
+
const result = runRecordSchema.safeParse(value);
|
|
471
|
+
if (result.success)
|
|
472
|
+
records.push(result.data);
|
|
452
473
|
} catch {}
|
|
453
474
|
}
|
|
454
475
|
return records.slice(-limit);
|
|
@@ -999,7 +1020,13 @@ function scheduleJobOutput(input, directory) {
|
|
|
999
1020
|
} catch (error) {
|
|
1000
1021
|
return fail(errorMessage(error));
|
|
1001
1022
|
}
|
|
1002
|
-
const run = validateRunSpec(
|
|
1023
|
+
const run = validateRunSpec({
|
|
1024
|
+
prompt: input.prompt,
|
|
1025
|
+
command: input.command,
|
|
1026
|
+
arguments: input.arguments,
|
|
1027
|
+
agent: input.agent,
|
|
1028
|
+
model: input.model
|
|
1029
|
+
}, "job");
|
|
1003
1030
|
const session = validateSession(input.session, "job");
|
|
1004
1031
|
const guard = validateGuard(input.guard, "job");
|
|
1005
1032
|
const timeoutSeconds = validateTimeout(input.timeoutSeconds, "job");
|
package/dist/json.d.ts
CHANGED
|
@@ -1,4 +1 @@
|
|
|
1
|
-
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
2
|
-
export declare function stringProperty(record: Record<string, unknown>, key: string): string | undefined;
|
|
3
|
-
export declare function numberProperty(record: Record<string, unknown>, key: string): number | undefined;
|
|
4
1
|
export declare function errorMessage(error: unknown): string;
|
package/dist/runs.d.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const runRecordSchema: z.ZodObject<{
|
|
3
|
+
runId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
4
|
+
slug: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
5
|
+
scopeId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
6
|
+
startedAt: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
7
|
+
finishedAt: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
8
|
+
durationMs: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
9
|
+
status: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
10
|
+
exitCode: z.ZodCatch<z.ZodOptional<z.ZodNumber>>;
|
|
11
|
+
sessionId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
12
|
+
startedBy: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export type RunRecord = z.infer<typeof runRecordSchema>;
|
|
13
15
|
export declare function readRunRecords(scopeId: string, slug: string, limit: number): RunRecord[];
|
|
14
16
|
export declare function lastFinishedRun(records: RunRecord[]): RunRecord | undefined;
|
|
15
17
|
export declare function formatRunLine(record: RunRecord): string;
|
|
16
18
|
export declare function tailFile(file: string, lines: number, maxChars: number): string | undefined;
|
|
19
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-jobs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "opencode plugin that schedules recurring agent jobs as systemd user timers, with git-committable job definitions, run history, and session continuity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -24,17 +24,14 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin && bun build src/cli.ts --outfile dist/cli.js --target node --format esm && tsc -p tsconfig.build.json",
|
|
27
|
+
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @opencode-ai/plugin --external zod && bun build src/cli.ts --outfile dist/cli.js --target node --format esm --external zod && tsc -p tsconfig.build.json",
|
|
28
28
|
"clean": "rm -rf dist",
|
|
29
29
|
"prepublishOnly": "npm run clean && npm run build",
|
|
30
30
|
"format": "prettier --write .",
|
|
31
31
|
"lint": "eslint",
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
33
|
"check": "prettier --check . && eslint && tsc --noEmit",
|
|
34
|
-
"smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs"
|
|
35
|
-
"release:patch": "npm version patch && npm publish",
|
|
36
|
-
"release:minor": "npm version minor && npm publish",
|
|
37
|
-
"release:major": "npm version major && npm publish"
|
|
34
|
+
"smoke": "npm run build && node scripts/smoke.mjs && node scripts/cli-smoke.mjs"
|
|
38
35
|
},
|
|
39
36
|
"keywords": [
|
|
40
37
|
"opencode",
|
|
@@ -59,7 +56,8 @@
|
|
|
59
56
|
},
|
|
60
57
|
"homepage": "https://github.com/Fraser-Grant/opencode-jobs#readme",
|
|
61
58
|
"dependencies": {
|
|
62
|
-
"@opencode-ai/plugin": "^1.18.18"
|
|
59
|
+
"@opencode-ai/plugin": "^1.18.18",
|
|
60
|
+
"zod": "4.1.8"
|
|
63
61
|
},
|
|
64
62
|
"peerDependencies": {
|
|
65
63
|
"@opencode-ai/plugin": ">=1.0.0"
|