syndai 0.1.1 → 0.4.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/CHANGELOG.md +55 -0
- package/README.md +97 -52
- package/bin/syndai.js +13 -29
- package/package.json +6 -11
- package/src/api.js +188 -56
- package/src/auth.js +245 -0
- package/src/changes.js +350 -0
- package/src/errors.js +148 -0
- package/src/http.js +166 -0
- package/src/main.js +249 -0
- package/src/mcp.js +93 -0
- package/src/openapi.js +145 -0
- package/src/projects.js +168 -0
- package/src/routes.js +65 -0
- package/src/schedules.js +377 -0
- package/src/settings.js +130 -0
- package/src/store.js +137 -0
- package/src/ui.js +156 -0
- package/src/watch.js +299 -0
- package/LICENSE +0 -9
- package/src/claude.js +0 -145
- package/src/codex.js +0 -71
- package/src/connect.js +0 -122
- package/src/proc.js +0 -36
- package/src/prompt.js +0 -43
- package/src/tokens.js +0 -45
package/src/schedules.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// `syndai schedule` (a project's recurring runs) and `syndai repeat` (repeat a run you liked).
|
|
2
|
+
//
|
|
3
|
+
// The server owns cron: it labels a cadence (`cadence_label`) and lists the next fires
|
|
4
|
+
// (`upcoming`), so nothing here parses cron. The only mapping is the presets the portal's
|
|
5
|
+
// Repeats field offers (apps/portal-v2/src/lib/cadence.ts `toCron`), with Sunday = 0.
|
|
6
|
+
// Merge and skip policy are edited in the portal or with `syndai api updateSchedule`.
|
|
7
|
+
|
|
8
|
+
import { ApiError, CliError, EXIT } from "./errors.js";
|
|
9
|
+
import { projectLabel, resolveProject } from "./projects.js";
|
|
10
|
+
import { ask, ulid } from "./ui.js";
|
|
11
|
+
|
|
12
|
+
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
13
|
+
const MAX_PAGES = 50;
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
const PAUSE_REASONS = {
|
|
16
|
+
consecutive_failures: "it failed 5 times in a row",
|
|
17
|
+
invalid_trigger: "its schedule is no longer valid",
|
|
18
|
+
};
|
|
19
|
+
const CADENCE_HELP = 'hourly --at :MM, daily|weekdays --at HH:MM, weekly --at "mon HH:MM", or a 5-field cron';
|
|
20
|
+
|
|
21
|
+
// --- cadence -------------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** The IANA zone to use: --tz (checked), else this computer's. */
|
|
24
|
+
export function timeZoneOf(flags) {
|
|
25
|
+
const zone = flags.tz ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
|
|
26
|
+
try {
|
|
27
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
28
|
+
} catch {
|
|
29
|
+
throw new CliError(EXIT.USAGE, `Unknown time zone "${zone}"; use an IANA name like Europe/Berlin.`);
|
|
30
|
+
}
|
|
31
|
+
return zone;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wall-clock hour, minute, second and weekday (0 = Sunday) of `now` in `zone`. */
|
|
35
|
+
function clock(now, zone) {
|
|
36
|
+
const parts = Object.fromEntries(
|
|
37
|
+
new Intl.DateTimeFormat("en-US", {
|
|
38
|
+
timeZone: zone,
|
|
39
|
+
hourCycle: "h23",
|
|
40
|
+
hour: "2-digit",
|
|
41
|
+
minute: "2-digit",
|
|
42
|
+
second: "2-digit",
|
|
43
|
+
weekday: "short",
|
|
44
|
+
})
|
|
45
|
+
.formatToParts(new Date(now))
|
|
46
|
+
.map((p) => [p.type, p.value]),
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
hh: Number(parts.hour) % 24,
|
|
50
|
+
mm: Number(parts.minute),
|
|
51
|
+
ss: Number(parts.second),
|
|
52
|
+
dow: DAYS.indexOf(parts.weekday.slice(0, 3).toLowerCase()),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `--every` (+ `--at`) -> a 5-field cron, as the portal's `toCron` writes it. `--at` defaults
|
|
58
|
+
* to the next quarter hour in the schedule's zone (the portal's `defaultCadence`).
|
|
59
|
+
*/
|
|
60
|
+
export function cronFor({ every, at }, now, zone) {
|
|
61
|
+
const fail = (why) => new CliError(EXIT.USAGE, `${why} --every takes ${CADENCE_HELP}.`);
|
|
62
|
+
if (!every) throw fail("How often?");
|
|
63
|
+
const fields = every.trim().split(/\s+/);
|
|
64
|
+
if (fields.length === 5) {
|
|
65
|
+
if (at !== undefined) throw fail("--at does not go with a cron expression.");
|
|
66
|
+
return fields.join(" ");
|
|
67
|
+
}
|
|
68
|
+
const preset = every.trim().toLowerCase();
|
|
69
|
+
if (!["hourly", "daily", "weekdays", "weekly"].includes(preset)) throw fail(`"${every}" is not a cadence.`);
|
|
70
|
+
|
|
71
|
+
const c = clock(now, zone);
|
|
72
|
+
const minutes = (Math.ceil((c.hh * 60 + c.mm + (c.ss > 0 ? 1 : 0)) / 15) * 15) % 1440;
|
|
73
|
+
let hh = Math.floor(minutes / 60);
|
|
74
|
+
let mm = minutes % 60;
|
|
75
|
+
let dow = c.dow;
|
|
76
|
+
if (at !== undefined) {
|
|
77
|
+
const text = at.trim().toLowerCase();
|
|
78
|
+
let match;
|
|
79
|
+
if (preset === "hourly") {
|
|
80
|
+
match = /^:?(\d{1,2})$/.exec(text);
|
|
81
|
+
if (!match || Number(match[1]) > 59) throw fail(`--at for hourly is :MM, not "${at}".`);
|
|
82
|
+
mm = Number(match[1]);
|
|
83
|
+
} else {
|
|
84
|
+
const shape = preset === "weekly" ? /^([a-z]+|[0-6])\s+(\d{1,2}):(\d{2})$/ : /^()(\d{1,2}):(\d{2})$/;
|
|
85
|
+
match = shape.exec(text);
|
|
86
|
+
const day = match?.[1] ? dayIndex(match[1]) : dow;
|
|
87
|
+
if (!match || Number(match[2]) > 23 || Number(match[3]) > 59 || day < 0) {
|
|
88
|
+
throw fail(`--at for ${preset} is ${preset === "weekly" ? '"mon HH:MM"' : "HH:MM"}, not "${at}".`);
|
|
89
|
+
}
|
|
90
|
+
[hh, mm, dow] = [Number(match[2]), Number(match[3]), day];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
hourly: `${mm} * * * *`,
|
|
95
|
+
daily: `${mm} ${hh} * * *`,
|
|
96
|
+
weekdays: `${mm} ${hh} * * 1-5`,
|
|
97
|
+
weekly: `${mm} ${hh} * * ${dow}`,
|
|
98
|
+
}[preset];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function dayIndex(text) {
|
|
102
|
+
if (/^[0-6]$/.test(text)) return Number(text);
|
|
103
|
+
const short = text.slice(0, 3);
|
|
104
|
+
const full = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
|
105
|
+
const index = DAYS.indexOf(short);
|
|
106
|
+
return index >= 0 && full[index].startsWith(text) ? index : -1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `--budget 5` -> 5; `--budget none` -> null (no per-run cap) where allowed. */
|
|
110
|
+
function budgetOf(text, { allowNone }) {
|
|
111
|
+
if (text === undefined) return undefined;
|
|
112
|
+
if (allowNone && text.trim().toLowerCase() === "none") return null;
|
|
113
|
+
const value = Number(text.replace(/^\$/, ""));
|
|
114
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
115
|
+
throw new CliError(EXIT.USAGE, `--budget is a dollar amount per run${allowNone ? ", or none" : ""}, not "${text}".`);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function triggerOf(ctx, { every, at, tz }) {
|
|
121
|
+
const zone = timeZoneOf({ tz });
|
|
122
|
+
if (tz === undefined && zone === "UTC" && ctx.ui.interactive) {
|
|
123
|
+
ctx.ui.progress(ctx.ui.dim("(your system time zone is UTC; pass --tz to change)"));
|
|
124
|
+
}
|
|
125
|
+
return { kind: "cron", expr: cronFor({ every, at }, ctx.io.now(), zone), timezone: zone };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- printing ------------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function when(iso, zone) {
|
|
131
|
+
if (!iso) return "-";
|
|
132
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
133
|
+
timeZone: zone,
|
|
134
|
+
weekday: "short",
|
|
135
|
+
month: "short",
|
|
136
|
+
day: "numeric",
|
|
137
|
+
hour: "numeric",
|
|
138
|
+
minute: "2-digit",
|
|
139
|
+
}).format(new Date(iso));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const labelOf = (s) => `${s.cadence_label ?? s.trigger.expr} ${s.trigger.timezone}`;
|
|
143
|
+
const firstLine = (text) => (text ?? "").split("\n")[0].slice(0, 60);
|
|
144
|
+
|
|
145
|
+
function stateOf(s) {
|
|
146
|
+
if (s.status !== "paused") return `next ${when(s.upcoming?.[0] ?? s.next_fire_at, s.trigger.timezone)}`;
|
|
147
|
+
const why = s.pause_reason && s.pause_reason !== "user" ? (PAUSE_REASONS[s.pause_reason] ?? s.pause_reason) : null;
|
|
148
|
+
return why ? `paused (${why})` : "paused";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function row(s) {
|
|
152
|
+
const last = s.last_occurrence ? (s.last_occurrence.summary ?? s.last_occurrence.disposition) : "";
|
|
153
|
+
return `${s.id.slice(0, 8)} ${labelOf(s)} ${stateOf(s)} "${firstLine(s.prompt)}"${last ? ` ${last}` : ""}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function upcomingLine(s) {
|
|
157
|
+
const times = (s.upcoming ?? []).map((t) => when(t, s.trigger.timezone));
|
|
158
|
+
return times.length ? `Next: ${times.join(" · ")}` : "Next: -";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function card(s, occurrences = []) {
|
|
162
|
+
const lines = [`${labelOf(s)} · ${stateOf(s)}`, `"${firstLine(s.prompt)}"`];
|
|
163
|
+
if (s.repo_name) lines.push(`Project ${s.repo_name}`);
|
|
164
|
+
if (s.occurrence_budget_usd !== null && s.occurrence_budget_usd !== undefined) {
|
|
165
|
+
lines.push(`Cap $${s.occurrence_budget_usd} per run`);
|
|
166
|
+
}
|
|
167
|
+
if (s.status !== "paused") lines.push(upcomingLine(s));
|
|
168
|
+
lines.push("", occurrences.length ? "History:" : "History: none yet");
|
|
169
|
+
for (const o of occurrences) {
|
|
170
|
+
const parts = [when(o.slot_ts, s.trigger.timezone), o.disposition, o.summary, o.coding_run_id].filter(Boolean);
|
|
171
|
+
lines.push(` ${parts.join(" · ")}`);
|
|
172
|
+
}
|
|
173
|
+
return lines.join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- API -----------------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
async function listAll(ctx, query) {
|
|
179
|
+
const schedules = [];
|
|
180
|
+
let cursor;
|
|
181
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
182
|
+
const res = await ctx.client.call("listSchedules", { query: { ...query, limit: 100, cursor } });
|
|
183
|
+
schedules.push(...res.schedules);
|
|
184
|
+
cursor = res.next_cursor;
|
|
185
|
+
if (!cursor) break;
|
|
186
|
+
}
|
|
187
|
+
return schedules;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A full id, or the 8-character prefix `schedule list` prints. */
|
|
191
|
+
async function scheduleId(ctx, ref) {
|
|
192
|
+
if (!ref) throw new CliError(EXIT.USAGE, "Which schedule? Its id is in `syndai schedule list`.");
|
|
193
|
+
if (UUID.test(ref)) return ref;
|
|
194
|
+
const hits = (await listAll(ctx, {})).filter((s) => s.id.startsWith(ref.toLowerCase()));
|
|
195
|
+
if (hits.length === 1) return hits[0].id;
|
|
196
|
+
throw new CliError(EXIT.USAGE, hits.length ? `"${ref}" matches ${hits.length} schedules; use more of the id.` : `No schedule "${ref}".`, {
|
|
197
|
+
next: "syndai schedule list --all",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create with a fresh request id (so a retried POST is the same create). A replay answers
|
|
203
|
+
* 409 + Location: that schedule is the result. A 409 without one is a changed replay.
|
|
204
|
+
*/
|
|
205
|
+
async function create(ctx, body) {
|
|
206
|
+
const res = await ctx.client.call("createSchedule", {
|
|
207
|
+
body: { ...body, client_request_id: `cli:${ulid()}` },
|
|
208
|
+
retry: true,
|
|
209
|
+
raw: true,
|
|
210
|
+
});
|
|
211
|
+
if (res.status < 300) return res.body;
|
|
212
|
+
const location = res.status === 409 ? res.headers.get("location") : null;
|
|
213
|
+
if (location) return (await ctx.client.send("GET", new URL(location, ctx.baseUrl).pathname)).schedule;
|
|
214
|
+
throw new ApiError(res.status, res.body, res.headers);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function printCreated(ctx, s, heading) {
|
|
218
|
+
ctx.ui.result(
|
|
219
|
+
s,
|
|
220
|
+
[heading, `${labelOf(s)}: "${firstLine(s.prompt)}"`, upcomingLine(s), `Manage: syndai schedule show ${s.id}`].join("\n"),
|
|
221
|
+
);
|
|
222
|
+
return EXIT.OK;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- commands ------------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
export async function schedule(ctx, [action, ...rest]) {
|
|
228
|
+
const handler = ACTIONS[action];
|
|
229
|
+
if (!handler) {
|
|
230
|
+
throw new CliError(EXIT.USAGE, "Usage: syndai schedule list|show|add|edit|pause|resume|run-now|delete", {
|
|
231
|
+
next: "syndai schedule list",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return handler(ctx, rest);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ACTIONS = {
|
|
238
|
+
async list(ctx) {
|
|
239
|
+
const { flags, ui } = ctx;
|
|
240
|
+
if (flags.all && flags.app) throw new CliError(EXIT.USAGE, "Pick one of --app and --all.");
|
|
241
|
+
const project = flags.all ? null : await resolveProject(ctx, { app: flags.app });
|
|
242
|
+
const schedules = await listAll(ctx, project ? { coding_repository_id: project.id } : {});
|
|
243
|
+
const where = project ? projectLabel(project) : "any project";
|
|
244
|
+
ui.result(
|
|
245
|
+
{ project_id: project?.id ?? null, schedules },
|
|
246
|
+
schedules.length
|
|
247
|
+
? schedules.map(row).join("\n")
|
|
248
|
+
: `Nothing repeats on ${where} yet. Add one: syndai schedule add "<what>" --every daily`,
|
|
249
|
+
);
|
|
250
|
+
return EXIT.OK;
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
async show(ctx, [ref]) {
|
|
254
|
+
const id = await scheduleId(ctx, ref);
|
|
255
|
+
const detail = await ctx.client.call("getSchedule", { params: { schedule_id: id } });
|
|
256
|
+
ctx.ui.result(detail, card(detail.schedule, detail.occurrences));
|
|
257
|
+
return EXIT.OK;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async add(ctx, words) {
|
|
261
|
+
const { flags } = ctx;
|
|
262
|
+
const prompt = words.join(" ").trim();
|
|
263
|
+
if (!prompt) throw new CliError(EXIT.USAGE, 'Say what to run: syndai schedule add "<what>" --every daily');
|
|
264
|
+
const trigger = triggerOf(ctx, flags);
|
|
265
|
+
const budget = budgetOf(flags.budget, { allowNone: false });
|
|
266
|
+
const project = await resolveProject(ctx, { app: flags.app });
|
|
267
|
+
const created = await create(ctx, {
|
|
268
|
+
prompt,
|
|
269
|
+
coding_repository_id: project.id,
|
|
270
|
+
trigger,
|
|
271
|
+
...(budget === undefined ? {} : { occurrence_budget_usd: budget }),
|
|
272
|
+
});
|
|
273
|
+
return printCreated(ctx, created, `Repeats on ${projectLabel(project)}.`);
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
async edit(ctx, [ref]) {
|
|
277
|
+
const { client, flags, ui } = ctx;
|
|
278
|
+
const id = await scheduleId(ctx, ref);
|
|
279
|
+
if ([flags.prompt, flags.every, flags.at, flags.tz, flags.budget].every((v) => v === undefined)) {
|
|
280
|
+
throw new CliError(EXIT.USAGE, "Nothing to change: pass --prompt, --every, --at, --tz or --budget.");
|
|
281
|
+
}
|
|
282
|
+
if (flags.at !== undefined && flags.every === undefined) {
|
|
283
|
+
throw new CliError(EXIT.USAGE, "--at goes with --every (the cadence it sets the time of).");
|
|
284
|
+
}
|
|
285
|
+
const { schedule: current } = await client.call("getSchedule", { params: { schedule_id: id } });
|
|
286
|
+
const patch = {};
|
|
287
|
+
if (flags.prompt !== undefined && flags.prompt !== current.prompt) patch.prompt = flags.prompt;
|
|
288
|
+
if (flags.every !== undefined || flags.tz !== undefined) {
|
|
289
|
+
const zone = timeZoneOf({ tz: flags.tz ?? current.trigger.timezone });
|
|
290
|
+
const expr = flags.every === undefined ? current.trigger.expr : cronFor(flags, ctx.io.now(), zone);
|
|
291
|
+
if (expr !== current.trigger.expr || zone !== current.trigger.timezone) {
|
|
292
|
+
patch.trigger = { kind: "cron", expr, timezone: zone };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const budget = budgetOf(flags.budget, { allowNone: true });
|
|
296
|
+
if (budget !== undefined && budget !== (current.occurrence_budget_usd ?? null)) {
|
|
297
|
+
patch.occurrence_budget_usd = budget;
|
|
298
|
+
}
|
|
299
|
+
if (Object.keys(patch).length === 0) {
|
|
300
|
+
ui.result(current, `Nothing changed. ${row(current)}`);
|
|
301
|
+
return EXIT.OK;
|
|
302
|
+
}
|
|
303
|
+
const updated = await client.call("updateSchedule", { params: { schedule_id: id }, body: patch });
|
|
304
|
+
ui.result(updated, `Updated. ${row(updated)}`);
|
|
305
|
+
return EXIT.OK;
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
async pause(ctx, [ref]) {
|
|
309
|
+
const id = await scheduleId(ctx, ref);
|
|
310
|
+
const s = await ctx.client.call("pauseSchedule", { params: { schedule_id: id } });
|
|
311
|
+
ctx.ui.result(s, `Paused. ${row(s)}`);
|
|
312
|
+
return EXIT.OK;
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async resume(ctx, [ref]) {
|
|
316
|
+
const id = await scheduleId(ctx, ref);
|
|
317
|
+
const s = await ctx.client.call("resumeSchedule", { params: { schedule_id: id } });
|
|
318
|
+
ctx.ui.result(s, `Resumed. ${row(s)}`);
|
|
319
|
+
return EXIT.OK;
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async "run-now"(ctx, [ref]) {
|
|
323
|
+
const id = await scheduleId(ctx, ref);
|
|
324
|
+
const res = await ctx.client.call("runScheduleNow", { params: { schedule_id: id }, raw: true });
|
|
325
|
+
if (res.status >= 400 && res.status !== 409) throw new ApiError(res.status, res.body, res.headers);
|
|
326
|
+
const already = res.status === 409;
|
|
327
|
+
ctx.ui.result(
|
|
328
|
+
{ schedule_id: id, started: !already, already_started: already },
|
|
329
|
+
`${already ? "It already started this minute." : "Started."} Its run shows in: syndai schedule show ${id}`,
|
|
330
|
+
);
|
|
331
|
+
return EXIT.OK;
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async delete(ctx, [ref]) {
|
|
335
|
+
const { flags, ui, io } = ctx;
|
|
336
|
+
const id = await scheduleId(ctx, ref);
|
|
337
|
+
if (!flags.yes) {
|
|
338
|
+
if (!ui.interactive) {
|
|
339
|
+
throw new CliError(EXIT.USAGE, "Deleting a schedule needs --yes when nobody can confirm it.", {
|
|
340
|
+
next: `syndai schedule delete ${id} --yes`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const answer = await ask(io, `Delete schedule ${id.slice(0, 8)} and its history? [y/N] `);
|
|
344
|
+
if (!/^y/i.test(answer)) {
|
|
345
|
+
ui.result({ schedule_id: id, deleted: false }, "Kept.");
|
|
346
|
+
return EXIT.OK;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
await ctx.client.call("deleteSchedule", { params: { schedule_id: id } });
|
|
350
|
+
ui.result({ schedule_id: id, deleted: true }, "Deleted.");
|
|
351
|
+
return EXIT.OK;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Repeat a run: the server copies its original prompt and project (`from_coding_run_id`).
|
|
357
|
+
* The first run is the next scheduled time, never now: re-running a run that just finished
|
|
358
|
+
* would charge twice.
|
|
359
|
+
*/
|
|
360
|
+
export async function repeat(ctx, [runId]) {
|
|
361
|
+
const { client, flags } = ctx;
|
|
362
|
+
if (!runId) throw new CliError(EXIT.USAGE, "Which run? syndai repeat <run> --every weekly");
|
|
363
|
+
const trigger = triggerOf(ctx, flags);
|
|
364
|
+
const budget = budgetOf(flags.budget, { allowNone: false });
|
|
365
|
+
const { run } = await client.call("getRun", { params: { coding_run_id: runId } });
|
|
366
|
+
const created = await create(ctx, {
|
|
367
|
+
from_coding_run_id: runId,
|
|
368
|
+
trigger,
|
|
369
|
+
...(budget === undefined ? {} : { occurrence_budget_usd: budget }),
|
|
370
|
+
});
|
|
371
|
+
const cost = typeof run.cost_usd === "number" ? ` (last run cost $${run.cost_usd.toFixed(2)})` : "";
|
|
372
|
+
return printCreated(
|
|
373
|
+
ctx,
|
|
374
|
+
created,
|
|
375
|
+
`Repeating "${firstLine(run.title ?? run.prompt)}"${cost}. First run ${when(created.upcoming?.[0], created.trigger.timezone)}.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// `syndai settings`: one project's owner settings.
|
|
2
|
+
//
|
|
3
|
+
// Settings live in four places server-side:
|
|
4
|
+
// autonomy repo-scoped policy default GET/PUT policy/defaults
|
|
5
|
+
// feature_requests the app's publication GET/PATCH .../publication
|
|
6
|
+
// bug_scan the repo's Bug Finder schedule GET/PUT repositories/{id}/bug-scan
|
|
7
|
+
// protected_paths the repo row PATCH repos/{id} (a key may only tighten)
|
|
8
|
+
|
|
9
|
+
import { ApiError, CliError, EXIT, explain } from "./errors.js";
|
|
10
|
+
import { projectLabel, resolveProject } from "./projects.js";
|
|
11
|
+
|
|
12
|
+
const VALUES = {
|
|
13
|
+
autonomy: ["gated", "auto", "merge"],
|
|
14
|
+
feature_requests: ["off", "members", "public"],
|
|
15
|
+
bug_scan: ["on", "off"],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function settings(ctx, args) {
|
|
19
|
+
const { flags, ui } = ctx;
|
|
20
|
+
const [action = "get", ...pairs] = args;
|
|
21
|
+
if (!["get", "set"].includes(action) || (action === "set" && pairs.length === 0)) {
|
|
22
|
+
throw new CliError(EXIT.USAGE, "Usage: syndai settings [get | set key=value...]", {
|
|
23
|
+
next: "syndai settings set autonomy=auto",
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
const project = await resolveProject(ctx, { app: flags.app });
|
|
27
|
+
for (const pair of pairs) await apply(ctx, project, parsePair(pair));
|
|
28
|
+
const current = await read(ctx, project);
|
|
29
|
+
const show = (value) =>
|
|
30
|
+
value === null ? "-" : Array.isArray(value) ? value.join(", ") || "(none)" : value;
|
|
31
|
+
const lines = [ui.bold(projectLabel(project))];
|
|
32
|
+
for (const key of ["autonomy", "feature_requests", "bug_scan", "protected_paths"]) {
|
|
33
|
+
const why = current.unavailable[key];
|
|
34
|
+
lines.push(` ${key.padEnd(17)}${why ? ui.dim(why) : show(current[key])}`);
|
|
35
|
+
}
|
|
36
|
+
ui.result({ project_id: project.id, ...current }, lines.join("\n"));
|
|
37
|
+
return EXIT.OK;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parsePair(pair) {
|
|
41
|
+
const match = /^([a-z_]+)(\+?=)(.*)$/.exec(pair);
|
|
42
|
+
const known = match && (match[1] in VALUES || match[1] === "protected_paths");
|
|
43
|
+
if (!known || (match[2] === "+=" && match[1] !== "protected_paths")) {
|
|
44
|
+
throw new CliError(
|
|
45
|
+
EXIT.USAGE,
|
|
46
|
+
`Cannot set "${pair}". Keys: autonomy, feature_requests, bug_scan, protected_paths (= or +=).`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const [, key, op, value] = match;
|
|
50
|
+
if (VALUES[key] && !VALUES[key].includes(value)) {
|
|
51
|
+
throw new CliError(EXIT.USAGE, `${key} is one of ${VALUES[key].join(", ")}.`);
|
|
52
|
+
}
|
|
53
|
+
return { key, op, value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function apply(ctx, project, { key, op, value }) {
|
|
57
|
+
const { client } = ctx;
|
|
58
|
+
if (key === "autonomy") {
|
|
59
|
+
const current = await orNull(client.call("getPolicy", { query: { coding_repository_id: project.id } }));
|
|
60
|
+
await client.call("putPolicy", {
|
|
61
|
+
body: { coding_repository_id: project.id, config: { ...(current?.config ?? {}), autonomy: value } },
|
|
62
|
+
});
|
|
63
|
+
} else if (key === "feature_requests") {
|
|
64
|
+
await client.call("updatePublication", {
|
|
65
|
+
params: { customer_id: project.customer_id, coding_repository_id: project.id },
|
|
66
|
+
body: { feature_requests: value },
|
|
67
|
+
});
|
|
68
|
+
} else if (key === "bug_scan") {
|
|
69
|
+
await client.call("setBugScan", {
|
|
70
|
+
params: { repository_id: project.id },
|
|
71
|
+
body: { enabled: value === "on" },
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
const added = value
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((p) => p.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
const next = op === "+=" ? [...project.protected_paths, ...added] : added;
|
|
79
|
+
// The server refuses a loosening from a key with HUMAN_APPROVAL_REQUIRED + review_url.
|
|
80
|
+
const repo = await client.call("updateRepo", {
|
|
81
|
+
params: { coding_repository_id: project.id },
|
|
82
|
+
body: { protected_paths: next },
|
|
83
|
+
});
|
|
84
|
+
project.protected_paths = repo.protected_paths ?? next;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function read(ctx, project) {
|
|
89
|
+
const { client } = ctx;
|
|
90
|
+
const ids = { customer_id: project.customer_id, coding_repository_id: project.id };
|
|
91
|
+
const [policy, publication, bugScan] = await Promise.all([
|
|
92
|
+
settle(client.call("getPolicy", { query: { coding_repository_id: project.id } })),
|
|
93
|
+
settle(client.call("getPublication", { params: ids })),
|
|
94
|
+
settle(client.call("getBugScan", { params: { repository_id: project.id } })),
|
|
95
|
+
]);
|
|
96
|
+
const unavailable = {};
|
|
97
|
+
for (const [key, got] of [
|
|
98
|
+
["autonomy", policy],
|
|
99
|
+
["feature_requests", publication],
|
|
100
|
+
["bug_scan", bugScan],
|
|
101
|
+
]) {
|
|
102
|
+
if (got.error) unavailable[key] = got.error;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
autonomy: policy.value?.config?.autonomy ?? null,
|
|
106
|
+
feature_requests: publication.value?.feature_requests ?? null,
|
|
107
|
+
bug_scan: bugScan.value ? (bugScan.value.enabled ? "on" : "off") : null,
|
|
108
|
+
protected_paths: project.protected_paths,
|
|
109
|
+
unavailable,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A read that may be missing (404 -> null) or not allowed for this key or plan (-> error). */
|
|
114
|
+
async function settle(promise) {
|
|
115
|
+
try {
|
|
116
|
+
return { value: await promise };
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (!(err instanceof ApiError)) throw err;
|
|
119
|
+
return err.status === 404 ? { value: null } : { error: explain(err).message };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function orNull(promise) {
|
|
124
|
+
try {
|
|
125
|
+
return await promise;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof ApiError && err.status === 404) return null;
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Local files, all outside any repository (`.syndai/**` in a repo is a protected path):
|
|
2
|
+
// $XDG_CONFIG_HOME/syndai/credentials.json keys, by workspace (customer), mode 0600
|
|
3
|
+
// $XDG_CONFIG_HOME/syndai/links.json directory -> project
|
|
4
|
+
// $XDG_STATE_HOME/syndai/pending/*.json a change's request id, written BEFORE its POST
|
|
5
|
+
// $XDG_CACHE_HOME/syndai/openapi-<host>.json the live API description (24h)
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { CliError, EXIT } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
const PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export function storePaths(env) {
|
|
15
|
+
const home = env.HOME || homedir();
|
|
16
|
+
const config = path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "syndai");
|
|
17
|
+
const state = path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "syndai");
|
|
18
|
+
const cache = path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "syndai");
|
|
19
|
+
return {
|
|
20
|
+
home,
|
|
21
|
+
credentials: path.join(config, "credentials.json"),
|
|
22
|
+
links: path.join(config, "links.json"),
|
|
23
|
+
pending: path.join(state, "pending"),
|
|
24
|
+
cache,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readJson(file, fallback) {
|
|
29
|
+
let text;
|
|
30
|
+
try {
|
|
31
|
+
text = fs.readFileSync(file, "utf8");
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err.code === "ENOENT") return fallback;
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new CliError(EXIT.FAILED, `${file} is not valid JSON; fix or delete it.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Atomic write (temp file + rename) with an exact mode, whatever the umask. */
|
|
44
|
+
export function writeFileAtomic(file, text, mode = 0o600) {
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
46
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
47
|
+
fs.writeFileSync(tmp, text, { mode });
|
|
48
|
+
fs.chmodSync(tmp, mode);
|
|
49
|
+
fs.renameSync(tmp, file);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function writeJson(file, data, mode = 0o600) {
|
|
53
|
+
writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`, mode);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- credentials -------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export function loadCredentials(paths) {
|
|
59
|
+
const data = readJson(paths.credentials, null);
|
|
60
|
+
return data && typeof data.customers === "object" ? data : { default: null, customers: {} };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function saveCredential(paths, { customer_id, api_key, key_id, expires_at }) {
|
|
64
|
+
const data = loadCredentials(paths);
|
|
65
|
+
data.customers[customer_id] = { api_key, key_id, expires_at };
|
|
66
|
+
data.default = customer_id;
|
|
67
|
+
writeJson(paths.credentials, data);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function removeCredential(paths, customerId) {
|
|
71
|
+
const data = loadCredentials(paths);
|
|
72
|
+
delete data.customers[customerId];
|
|
73
|
+
if (data.default === customerId) data.default = Object.keys(data.customers)[0] ?? null;
|
|
74
|
+
writeJson(paths.credentials, data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- links -------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** The link for `cwd` or its nearest linked parent, or null. */
|
|
80
|
+
export function findLink(paths, cwd) {
|
|
81
|
+
const links = readJson(paths.links, {});
|
|
82
|
+
let dir = path.resolve(cwd);
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (links[dir]) return { dir, ...links[dir] };
|
|
85
|
+
const parent = path.dirname(dir);
|
|
86
|
+
if (parent === dir) return null;
|
|
87
|
+
dir = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function saveLink(paths, dir, link) {
|
|
92
|
+
const links = readJson(paths.links, {});
|
|
93
|
+
links[path.resolve(dir)] = link;
|
|
94
|
+
writeJson(paths.links, links);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- pending change requests -------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function pendingFile(paths, requestId) {
|
|
100
|
+
return path.join(paths.pending, `${requestId.replace(/[^\w-]/g, "_")}.json`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A not-yet-confirmed request for the same project and text (a crash or a retry), or null. */
|
|
104
|
+
export function findPending(paths, { app, text }, now = Date.now()) {
|
|
105
|
+
let names;
|
|
106
|
+
try {
|
|
107
|
+
names = fs.readdirSync(paths.pending);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.code === "ENOENT") return null;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
let match = null;
|
|
113
|
+
for (const name of names.filter((n) => n.endsWith(".json"))) {
|
|
114
|
+
const file = path.join(paths.pending, name);
|
|
115
|
+
let record;
|
|
116
|
+
try {
|
|
117
|
+
record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
118
|
+
} catch {
|
|
119
|
+
fs.rmSync(file, { force: true });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (now - Date.parse(record.created_at) > PENDING_TTL_MS) {
|
|
123
|
+
fs.rmSync(file, { force: true });
|
|
124
|
+
} else if (record.app === app && record.text === text) {
|
|
125
|
+
match = record;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return match;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function writePending(paths, record) {
|
|
132
|
+
writeJson(pendingFile(paths, record.request_id), record);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function clearPending(paths, requestId) {
|
|
136
|
+
fs.rmSync(pendingFile(paths, requestId), { force: true });
|
|
137
|
+
}
|