@stringsync/splates 0.0.52 → 0.0.53
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/artifact.ts +120 -0
- package/budget.ts +117 -0
- package/checkout.ts +8 -0
- package/creds.test.ts +336 -0
- package/creds.ts +144 -0
- package/dev/dev.ts +86 -0
- package/dev/eval.ts +21 -0
- package/dev/fix.ts +63 -0
- package/dev/index.ts +1 -0
- package/dev/prove.ts +38 -0
- package/dev/root.ts +7 -0
- package/dev/ship.ts +46 -0
- package/dev/test.ts +154 -0
- package/e2e.test.ts +472 -0
- package/flags.ts +157 -0
- package/hook.ts +328 -0
- package/index.ts +18 -0
- package/infra.ts +107 -0
- package/lane.test.ts +29 -0
- package/lane.ts +172 -0
- package/notes.ts +19 -0
- package/package.json +14 -12
- package/projects.test.ts +191 -0
- package/projects.ts +37 -0
- package/queue.ts +82 -0
- package/screenshots/annotate.png +0 -0
- package/screenshots/demo.png +0 -0
- package/screenshots/parallel.png +0 -0
- package/screenshots/questions.png +0 -0
- package/server.ts +287 -0
- package/setup.ts +233 -0
- package/slots.ts +65 -0
- package/splates.test.ts +338 -0
- package/splates.ts +567 -0
- package/task.ts +354 -0
- package/tunnel.ts +106 -0
- package/wait-journal.test.ts +96 -0
- package/waiting.ts +59 -0
- package/worker-relay.test.ts +233 -0
- package/worker.ts +300 -0
- package/artifact.d.ts +0 -3
- package/budget.d.ts +0 -3
- package/creds.d.ts +0 -3
- package/flags.d.ts +0 -37
- package/hook.d.ts +0 -3
- package/index.d.ts +0 -2
- package/index.js +0 -1776
- package/infra.d.ts +0 -3
- package/lane.d.ts +0 -3
- package/notes.d.ts +0 -4
- package/projects.d.ts +0 -3
- package/queue.d.ts +0 -3
- package/server.d.ts +0 -7
- package/setup.d.ts +0 -22
- package/slots.d.ts +0 -3
- package/splates.d.ts +0 -10
- package/task.d.ts +0 -3
- package/tunnel.d.ts +0 -3
- package/waiting.d.ts +0 -3
- package/worker.d.ts +0 -3
package/hook.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { commaList } from "@stringsync/splates-server/comma-list";
|
|
2
|
+
import { fail } from "@stringsync/splates-server/exit";
|
|
3
|
+
import {
|
|
4
|
+
type Hook,
|
|
5
|
+
HookStore,
|
|
6
|
+
hookAdd,
|
|
7
|
+
hookEdit,
|
|
8
|
+
hookGardener,
|
|
9
|
+
hookGroomer,
|
|
10
|
+
hookList,
|
|
11
|
+
hookPause,
|
|
12
|
+
hookRemove,
|
|
13
|
+
lanesNamed,
|
|
14
|
+
stagesNamed,
|
|
15
|
+
TASK_EVENTS,
|
|
16
|
+
type TaskEventName,
|
|
17
|
+
type TaskFilter,
|
|
18
|
+
type Trigger,
|
|
19
|
+
} from "@stringsync/splates-server/hooks";
|
|
20
|
+
import { repository } from "@stringsync/splates-server/records";
|
|
21
|
+
import type { SplatesDeps } from "@stringsync/splates-server/server";
|
|
22
|
+
import type { Storage } from "@stringsync/splates-server/storage";
|
|
23
|
+
import { cli } from "webappwiz/cmd";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
import { assigneeIn } from "./flags";
|
|
26
|
+
|
|
27
|
+
/** `splates hook`, which the root cli mounts beside `splates queue`. */
|
|
28
|
+
export const splatesHook = cli<SplatesDeps>("hook").description(
|
|
29
|
+
`automations: what runs on a task event (${TASK_EVENTS.join(", ")}), as \`splates server\` sees the journal take it, or on a schedule it keeps: an agent told of it; or a shell command. Kept for you in this project's .git/splates, never checked in, since an agent hook spends your tokens; splates.config.ts's \`on\` holds the shell commands a project checks in. None set, nothing runs. The page's Automations tab lists them with when each last ran and for what task`,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
splatesHook
|
|
33
|
+
.command("list")
|
|
34
|
+
.use(repository({ needs: "read" }))
|
|
35
|
+
.description("every automation set here, by number, with its trigger")
|
|
36
|
+
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
37
|
+
.action((opts, ctx) =>
|
|
38
|
+
hookList({ hooks: storeOf(ctx), log: ctx.log }, { json: opts.json }),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
splatesHook
|
|
42
|
+
.command("add")
|
|
43
|
+
.use(repository({ needs: "tasks" }))
|
|
44
|
+
.description(
|
|
45
|
+
"run something on a task event, or on a schedule with `--every` or `--cron` in place of the event: `--agent <a> --prompt <words>` starts that agent in the main tree, told the event as `[SPLATES EVENT <event> #N]`, or a schedule as `[SPLATES SCHEDULE <number>]`, then your words; `--run <command>` runs it via `sh -c` in the main tree, with $SPLATES_EVENT (`schedule` for one), $SPLATES_TASKS and $SPLATES_TASK set. A schedule that came due while `splates server` was down runs once as it starts",
|
|
46
|
+
)
|
|
47
|
+
.arg("event", z.string().optional(), {
|
|
48
|
+
description: "the task event, as task.created; none for a schedule",
|
|
49
|
+
})
|
|
50
|
+
.option("stage", z.string(), {
|
|
51
|
+
default: "",
|
|
52
|
+
description:
|
|
53
|
+
"only for tasks in these stages, comma separated: backlog, ready, queued, running",
|
|
54
|
+
})
|
|
55
|
+
.option("lane", z.string(), {
|
|
56
|
+
default: "",
|
|
57
|
+
description: "only for tasks in these lanes, by number, comma separated",
|
|
58
|
+
})
|
|
59
|
+
.option("every", z.string(), {
|
|
60
|
+
default: "",
|
|
61
|
+
description: "run every so often, as 30m, 2h or 1d",
|
|
62
|
+
})
|
|
63
|
+
.option("cron", z.string(), {
|
|
64
|
+
default: "",
|
|
65
|
+
description:
|
|
66
|
+
'run on a five-field cron line, in this machine\'s time, as "0 9 * * 1-5"',
|
|
67
|
+
})
|
|
68
|
+
.option("agent", z.string(), {
|
|
69
|
+
default: "",
|
|
70
|
+
description: "the agent to run, as `splates agents` names it",
|
|
71
|
+
})
|
|
72
|
+
.option("model", z.string(), {
|
|
73
|
+
default: "",
|
|
74
|
+
description: "its model (default: the agent's own)",
|
|
75
|
+
})
|
|
76
|
+
.option("effort", z.string(), {
|
|
77
|
+
default: "",
|
|
78
|
+
description: "its thinking level (default: the agent's own)",
|
|
79
|
+
})
|
|
80
|
+
.option("prompt", z.string(), {
|
|
81
|
+
default: "",
|
|
82
|
+
description: "what the agent is told after the event",
|
|
83
|
+
})
|
|
84
|
+
.option("run", z.string(), {
|
|
85
|
+
default: "",
|
|
86
|
+
description: "a shell command to run",
|
|
87
|
+
})
|
|
88
|
+
.action(async (opts, ctx) => {
|
|
89
|
+
const pick = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
|
|
90
|
+
let hook: Hook;
|
|
91
|
+
if (opts.run !== "") {
|
|
92
|
+
if (pick != null || opts.prompt !== "") {
|
|
93
|
+
fail("usage", "--run goes alone: a shell command runs no agent", {});
|
|
94
|
+
}
|
|
95
|
+
hook = { kind: "run", command: opts.run };
|
|
96
|
+
} else if (pick == null) {
|
|
97
|
+
fail("usage", "say what runs: --agent with --prompt, or --run", {});
|
|
98
|
+
} else if (opts.prompt === "") {
|
|
99
|
+
fail("usage", "an agent hook needs --prompt: what to tell it", {});
|
|
100
|
+
} else {
|
|
101
|
+
hook = { kind: "agent", prompt: opts.prompt, ...pick };
|
|
102
|
+
}
|
|
103
|
+
await hookAdd({ hooks: storeOf(ctx), log: ctx.log }, triggerOf(opts), hook);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
splatesHook
|
|
107
|
+
.command("edit")
|
|
108
|
+
.use(repository({ needs: "tasks" }))
|
|
109
|
+
.description(
|
|
110
|
+
"change one of your automations, by its number in `splates hook list`, keeping its number, its pause and its last runs. Each flag changes only what it names, as `splates hook add` takes it: `--event`, `--every` or `--cron` sets what sets it off anew, `--stage` or `--lane` alone narrows its event again (`any` for no narrowing), `--prompt` or `--agent` changes what an agent is told or which agent, and `--run` makes it a shell command. The groomer changes through `splates hook groomer`",
|
|
111
|
+
)
|
|
112
|
+
.arg("id", z.string(), { description: "its number in `splates hook list`" })
|
|
113
|
+
.option("event", z.string(), {
|
|
114
|
+
default: "",
|
|
115
|
+
description:
|
|
116
|
+
"a task event to run on, as task.created, in place of what sets it off now",
|
|
117
|
+
})
|
|
118
|
+
.option("stage", z.string(), {
|
|
119
|
+
default: "",
|
|
120
|
+
description:
|
|
121
|
+
"only for tasks in these stages, comma separated, or `any`: backlog, ready, queued, running",
|
|
122
|
+
})
|
|
123
|
+
.option("lane", z.string(), {
|
|
124
|
+
default: "",
|
|
125
|
+
description:
|
|
126
|
+
"only for tasks in these lanes, by number, comma separated, or `any`",
|
|
127
|
+
})
|
|
128
|
+
.option("every", z.string(), {
|
|
129
|
+
default: "",
|
|
130
|
+
description: "run every so often, as 30m, 2h or 1d",
|
|
131
|
+
})
|
|
132
|
+
.option("cron", z.string(), {
|
|
133
|
+
default: "",
|
|
134
|
+
description:
|
|
135
|
+
'run on a five-field cron line, in this machine\'s time, as "0 9 * * 1-5"',
|
|
136
|
+
})
|
|
137
|
+
.option("agent", z.string(), {
|
|
138
|
+
default: "",
|
|
139
|
+
description: "the agent to run, as `splates agents` names it",
|
|
140
|
+
})
|
|
141
|
+
.option("model", z.string(), {
|
|
142
|
+
default: "",
|
|
143
|
+
description: "its model, with --agent (default: the agent's own)",
|
|
144
|
+
})
|
|
145
|
+
.option("effort", z.string(), {
|
|
146
|
+
default: "",
|
|
147
|
+
description: "its thinking level, with --agent (default: the agent's own)",
|
|
148
|
+
})
|
|
149
|
+
.option("prompt", z.string(), {
|
|
150
|
+
default: "",
|
|
151
|
+
description: "what the agent is told after the event",
|
|
152
|
+
})
|
|
153
|
+
.option("run", z.string(), {
|
|
154
|
+
default: "",
|
|
155
|
+
description: "a shell command to run in place of an agent",
|
|
156
|
+
})
|
|
157
|
+
.action(async (opts, ctx) => {
|
|
158
|
+
const { id, agent, model, effort, ...change } = opts;
|
|
159
|
+
const pick = await assigneeIn(ctx, agent, model, effort);
|
|
160
|
+
if (pick === null) {
|
|
161
|
+
fail(
|
|
162
|
+
"usage",
|
|
163
|
+
"an agent automation needs an agent: name one, or --run",
|
|
164
|
+
{},
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
await hookEdit({ hooks: storeOf(ctx), log: ctx.log }, id, {
|
|
168
|
+
...change,
|
|
169
|
+
...(pick === undefined ? {} : { pick }),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
splatesHook
|
|
174
|
+
.command("remove")
|
|
175
|
+
.use(repository({ needs: "tasks" }))
|
|
176
|
+
.description(
|
|
177
|
+
"take away an automation, by its number in `splates hook list`, or every one on an event",
|
|
178
|
+
)
|
|
179
|
+
.arg("id", z.string(), {
|
|
180
|
+
description: "its number, or a task event, as task.created",
|
|
181
|
+
})
|
|
182
|
+
.action((opts, ctx) =>
|
|
183
|
+
hookRemove({ hooks: storeOf(ctx), log: ctx.log }, opts.id),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
splatesHook
|
|
187
|
+
.command("pause")
|
|
188
|
+
.use(repository({ needs: "tasks" }))
|
|
189
|
+
.description("stop an automation running, keeping it to resume")
|
|
190
|
+
.arg("id", z.string(), { description: "its number in `splates hook list`" })
|
|
191
|
+
.action((opts, ctx) =>
|
|
192
|
+
hookPause({ hooks: storeOf(ctx), log: ctx.log }, opts.id, true),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
splatesHook
|
|
196
|
+
.command("resume")
|
|
197
|
+
.use(repository({ needs: "tasks" }))
|
|
198
|
+
.description("let a paused automation run again")
|
|
199
|
+
.arg("id", z.string(), { description: "its number in `splates hook list`" })
|
|
200
|
+
.action((opts, ctx) =>
|
|
201
|
+
hookPause({ hooks: storeOf(ctx), log: ctx.log }, opts.id, false),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
splatesHook
|
|
205
|
+
.command("groomer")
|
|
206
|
+
.use(repository({ needs: "tasks" }))
|
|
207
|
+
.description(
|
|
208
|
+
"turn on the project's groomer, built-in automation `groomer`, or set the agent it runs as, as the Automations page does: on each task deferred to the backlog, and the backlog as `splates server` starts, a conversation of its own per run, archived as the run ends, organizes each task in order and marks it ready, never doing it. `--off` turns it off, retiring its conversations",
|
|
209
|
+
)
|
|
210
|
+
.option("agent", z.string(), {
|
|
211
|
+
default: "",
|
|
212
|
+
description: "the agent it runs as, as `splates agents` names it",
|
|
213
|
+
})
|
|
214
|
+
.option("model", z.string(), {
|
|
215
|
+
default: "",
|
|
216
|
+
description: "its model (default: the agent's own)",
|
|
217
|
+
})
|
|
218
|
+
.option("effort", z.string(), {
|
|
219
|
+
default: "",
|
|
220
|
+
description: "its thinking level (default: the agent's own)",
|
|
221
|
+
})
|
|
222
|
+
.option("off", z.boolean(), {
|
|
223
|
+
default: false,
|
|
224
|
+
description: "turn it off",
|
|
225
|
+
})
|
|
226
|
+
.action(async (opts, ctx) => {
|
|
227
|
+
const pick = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
|
|
228
|
+
if (opts.off === (pick != null)) {
|
|
229
|
+
fail(
|
|
230
|
+
"usage",
|
|
231
|
+
"say how it runs with --agent, or turn it off with --off",
|
|
232
|
+
{},
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
await hookGroomer({ hooks: storeOf(ctx), log: ctx.log }, pick ?? null);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
splatesHook
|
|
239
|
+
.command("gardener")
|
|
240
|
+
.use(repository({ needs: "tasks" }))
|
|
241
|
+
.description(
|
|
242
|
+
"turn on the notes gardener, built-in automation `notes-gardener`, or set the agent it runs as and its schedule, as the Automations page does: on each run, an agent reads each project note no open task names by its path, checks it against the code and the other notes, and drafts the fixes in one task, `notes-garden`: rewriting what went stale, merging notes that say one thing, splitting one that says several, settling a contradiction the code settles and sharpening descriptions, then asks you to review the change rather than merge it, asking about any contradiction the code does not settle. No run starts while `notes-garden` is open. Off by default, since each run reads code for every note on your tokens; it runs every 7d until you set `--every` or `--cron`. `--off` turns it off",
|
|
243
|
+
)
|
|
244
|
+
.option("agent", z.string(), {
|
|
245
|
+
default: "",
|
|
246
|
+
description: "the agent it runs as, as `splates agents` names it",
|
|
247
|
+
})
|
|
248
|
+
.option("model", z.string(), {
|
|
249
|
+
default: "",
|
|
250
|
+
description: "its model (default: the agent's own)",
|
|
251
|
+
})
|
|
252
|
+
.option("effort", z.string(), {
|
|
253
|
+
default: "",
|
|
254
|
+
description: "its thinking level (default: the agent's own)",
|
|
255
|
+
})
|
|
256
|
+
.option("every", z.string(), {
|
|
257
|
+
default: "",
|
|
258
|
+
description: "run every so often, as 1d (default: as it was, or 7d)",
|
|
259
|
+
})
|
|
260
|
+
.option("cron", z.string(), {
|
|
261
|
+
default: "",
|
|
262
|
+
description:
|
|
263
|
+
'run on a five-field cron line, in this machine\'s time, as "0 9 * * 1"',
|
|
264
|
+
})
|
|
265
|
+
.option("off", z.boolean(), {
|
|
266
|
+
default: false,
|
|
267
|
+
description: "turn it off",
|
|
268
|
+
})
|
|
269
|
+
.action(async (opts, ctx) => {
|
|
270
|
+
const pick = await assigneeIn(ctx, opts.agent, opts.model, opts.effort);
|
|
271
|
+
if (opts.off === (pick != null)) {
|
|
272
|
+
fail(
|
|
273
|
+
"usage",
|
|
274
|
+
"say how it runs with --agent, or turn it off with --off",
|
|
275
|
+
{},
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
await hookGardener({ hooks: storeOf(ctx), log: ctx.log }, pick ?? null, {
|
|
279
|
+
every: opts.every,
|
|
280
|
+
cron: opts.cron,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/** What sets the automation `splates hook add` adds off, refusing two triggers or none. */
|
|
285
|
+
function triggerOf(opts: TriggerOptions): Trigger {
|
|
286
|
+
const given = [opts.event, opts.every, opts.cron].filter(
|
|
287
|
+
(each) => each !== undefined && each !== "",
|
|
288
|
+
);
|
|
289
|
+
if (given.length !== 1) {
|
|
290
|
+
fail(
|
|
291
|
+
"usage",
|
|
292
|
+
"say what sets it off: a task event, --every or --cron, one of them",
|
|
293
|
+
{},
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (opts.event === undefined || opts.event === "") {
|
|
297
|
+
if (opts.stage !== "" || opts.lane !== "") {
|
|
298
|
+
fail(
|
|
299
|
+
"usage",
|
|
300
|
+
"--stage and --lane narrow a task event, not a schedule",
|
|
301
|
+
{},
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return opts.every !== "" ? { every: opts.every } : { cron: opts.cron };
|
|
305
|
+
}
|
|
306
|
+
const filter: TaskFilter = {};
|
|
307
|
+
if (opts.stage !== "") {
|
|
308
|
+
filter.stages = stagesNamed(commaList(opts.stage));
|
|
309
|
+
}
|
|
310
|
+
if (opts.lane !== "") {
|
|
311
|
+
filter.lanes = lanesNamed(opts.lane);
|
|
312
|
+
}
|
|
313
|
+
const event = opts.event as TaskEventName;
|
|
314
|
+
return Object.keys(filter).length === 0 ? { event } : { event, filter };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The options of `splates hook add` that say what sets it off. */
|
|
318
|
+
interface TriggerOptions {
|
|
319
|
+
event?: string;
|
|
320
|
+
every: string;
|
|
321
|
+
cron: string;
|
|
322
|
+
stage: string;
|
|
323
|
+
lane: string;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function storeOf(ctx: { storage: Storage }) {
|
|
327
|
+
return new HookStore(ctx.storage);
|
|
328
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
LEGACY,
|
|
5
|
+
legacyWarnings,
|
|
6
|
+
systemPs,
|
|
7
|
+
} from "@stringsync/splates-server/config";
|
|
8
|
+
import { assets } from "@stringsync/splates-server/server";
|
|
9
|
+
import { NodeFs } from "webappwiz/system";
|
|
10
|
+
import { projectArgs, splates } from "./splates";
|
|
11
|
+
|
|
12
|
+
// The bin's old name, kept for one release.
|
|
13
|
+
if (basename(process.argv[1] ?? "") === LEGACY) {
|
|
14
|
+
legacyWarnings.once(LEGACY, "splates");
|
|
15
|
+
}
|
|
16
|
+
const ps = systemPs();
|
|
17
|
+
const { project, argv } = projectArgs(ps.args);
|
|
18
|
+
await splates.run({ fs: new NodeFs(), ps, assets, project }, argv);
|
package/infra.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { commaList } from "@stringsync/splates-server/comma-list";
|
|
3
|
+
import {
|
|
4
|
+
ACCESS_ENVIRONMENT,
|
|
5
|
+
accessEnvironmentExists,
|
|
6
|
+
CoordinatorBox,
|
|
7
|
+
pulumiOrg,
|
|
8
|
+
writeInfraConfig,
|
|
9
|
+
} from "@stringsync/splates-server/coordinator";
|
|
10
|
+
import { fail } from "@stringsync/splates-server/exit";
|
|
11
|
+
import { infraConfigure } from "@stringsync/splates-server/infra/configure";
|
|
12
|
+
import { secretsEnvironmentUrl } from "@stringsync/splates-server/infra/secrets";
|
|
13
|
+
import { Projects } from "@stringsync/splates-server/projects";
|
|
14
|
+
import { repository } from "@stringsync/splates-server/records";
|
|
15
|
+
import type { SplatesDeps } from "@stringsync/splates-server/server";
|
|
16
|
+
import { savedTunnelOf } from "@stringsync/splates-server/server";
|
|
17
|
+
import { FsStorage } from "@stringsync/splates-server/storage";
|
|
18
|
+
import { cli } from "webappwiz/cmd";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
/** `splates server infra`, which `splates server` mounts. */
|
|
22
|
+
export const splatesInfra = cli<SplatesDeps>("infra").description(
|
|
23
|
+
"the Pulumi project that makes everything the server's box needs in AWS, Cloudflare and GitHub, which `splates server deploy` then installs splates on",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
splatesInfra
|
|
27
|
+
.command("init")
|
|
28
|
+
.use(repository({ needs: "own" }))
|
|
29
|
+
.description(
|
|
30
|
+
"write a Pulumi project in <dir>, its program a dozen lines using the components of @stringsync/splates-server/infra, and the stack's config from the flags; run again, it writes only what is not so already and keeps values you changed. It fills the stack's config from what splates keeps (the projects, the saved tunnel, the Cloudflare ids). The secrets never pass through splates: it makes the Pulumi ESC environment splates/secrets with a placeholder for each (the Cloudflare token, a GitHub token per owner, the Anthropic key), which the stack imports and the user replaces on the Pulumi site; `splates setup` has a step for each saying how to get it. When the Pulumi ESC environment `coordinator-access` makes (splates/aws) is there, the stack imports it. It records the directory in ~/.splates/coordinator/infra.json, where `splates server deploy` finds the stack. Its runtime is bun, which runs the program's TypeScript itself. Commit the project but not Pulumi.<stack>.yaml, which holds your own accounts and ids and which the .gitignore it writes keeps out of git; init writes it again from what splates keeps. Then `bun add @stringsync/splates-server @pulumi/pulumi @pulumi/aws @pulumi/awsx @pulumi/cloudflare @pulumi/github @pulumi/tls @pulumi/random` there and `pulumi up`",
|
|
31
|
+
)
|
|
32
|
+
.arg("dir", z.string(), { description: "where the Pulumi project goes" })
|
|
33
|
+
.option("stack", z.string(), { default: "prod", description: "the stack" })
|
|
34
|
+
.option("host", z.string(), {
|
|
35
|
+
default: "",
|
|
36
|
+
description:
|
|
37
|
+
"the public host name, as in splates.example.com; the saved tunnel's by default",
|
|
38
|
+
})
|
|
39
|
+
.option("email", z.string(), {
|
|
40
|
+
default: "",
|
|
41
|
+
description:
|
|
42
|
+
"the email address Access lets in; the tunnel's Access application's by default",
|
|
43
|
+
})
|
|
44
|
+
.option("region", z.string(), {
|
|
45
|
+
default: "us-east-1",
|
|
46
|
+
description: "the AWS region",
|
|
47
|
+
})
|
|
48
|
+
.option("projects", z.string(), {
|
|
49
|
+
default: "",
|
|
50
|
+
description:
|
|
51
|
+
"comma-separated slug=owner/repo, each a project the box pushes trunk of; every project registered here with a GitHub origin by default",
|
|
52
|
+
})
|
|
53
|
+
.action(async (opts, ctx) => {
|
|
54
|
+
const listed = commaList(opts.projects).map((entry) => {
|
|
55
|
+
const [slug, repository] = entry.split("=");
|
|
56
|
+
if (!slug || !repository?.includes("/")) {
|
|
57
|
+
fail("usage", `--projects wants slug=owner/repo, not ${entry}`, {
|
|
58
|
+
entry,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { slug, repository };
|
|
62
|
+
});
|
|
63
|
+
const org = await pulumiOrg(ctx.ps);
|
|
64
|
+
const home = new CoordinatorBox({ ps: ctx.ps, fs: ctx.fs }).home;
|
|
65
|
+
const { written, todo } = await infraConfigure({
|
|
66
|
+
dir: opts.dir,
|
|
67
|
+
stack: opts.stack,
|
|
68
|
+
host: opts.host,
|
|
69
|
+
email: opts.email,
|
|
70
|
+
region: opts.region,
|
|
71
|
+
projects: listed.length === 0 ? null : listed,
|
|
72
|
+
registered: async () =>
|
|
73
|
+
(await Projects.in(new FsStorage(home, ctx.fs), ctx.fs).list()).filter(
|
|
74
|
+
(project) => !project.gone,
|
|
75
|
+
),
|
|
76
|
+
creds: ctx.creds,
|
|
77
|
+
saved: savedTunnelOf(ctx),
|
|
78
|
+
// The sign-in `coordinator-access` made, once it is there.
|
|
79
|
+
...(org !== null && (await accessEnvironmentExists(ctx.ps, org))
|
|
80
|
+
? { environment: ACCESS_ENVIRONMENT }
|
|
81
|
+
: {}),
|
|
82
|
+
fs: ctx.fs,
|
|
83
|
+
ps: ctx.ps,
|
|
84
|
+
log: ctx.log,
|
|
85
|
+
fetch: ctx.fetch,
|
|
86
|
+
});
|
|
87
|
+
await writeInfraConfig(ctx.fs, home, {
|
|
88
|
+
dir: resolve(opts.dir),
|
|
89
|
+
stack: opts.stack,
|
|
90
|
+
});
|
|
91
|
+
ctx.log.info(
|
|
92
|
+
written.length === 0
|
|
93
|
+
? "already so: nothing written"
|
|
94
|
+
: `wrote ${written.join(", ")}`,
|
|
95
|
+
);
|
|
96
|
+
ctx.log.info(
|
|
97
|
+
`Pulumi.${opts.stack}.yaml stays out of git: it holds your own accounts and ids, and init writes it again from what splates keeps`,
|
|
98
|
+
);
|
|
99
|
+
for (const command of todo) {
|
|
100
|
+
ctx.log.info(`still to run in ${opts.dir}: ${command}`);
|
|
101
|
+
}
|
|
102
|
+
if (org !== null) {
|
|
103
|
+
ctx.log.info(
|
|
104
|
+
`paste each secret over its placeholder at ${secretsEnvironmentUrl(org)}: \`splates setup\` has a step for each saying how to get it`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
package/lane.test.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { expect, it } from "bun:test";
|
|
2
|
+
import { Testing } from "@stringsync/splates-server/testing";
|
|
3
|
+
import { splates } from "./splates";
|
|
4
|
+
|
|
5
|
+
it("addresses permanent Backlog by name in both task and lane commands", async () => {
|
|
6
|
+
await using testing = await Testing.open();
|
|
7
|
+
testing.ps.cd(testing.root);
|
|
8
|
+
const deps = {
|
|
9
|
+
log: testing.log,
|
|
10
|
+
fs: testing.fs,
|
|
11
|
+
ps: testing.ps,
|
|
12
|
+
assets: testing.assets,
|
|
13
|
+
secrets: testing.secrets,
|
|
14
|
+
};
|
|
15
|
+
await splates.run(deps, [
|
|
16
|
+
"task",
|
|
17
|
+
"defer",
|
|
18
|
+
"later",
|
|
19
|
+
"--slug",
|
|
20
|
+
"later",
|
|
21
|
+
"--lane",
|
|
22
|
+
"backlog",
|
|
23
|
+
]);
|
|
24
|
+
const [task] = await testing.tasks.all();
|
|
25
|
+
expect(task?.lane, testing.out()).toBe(0);
|
|
26
|
+
testing.log.clear();
|
|
27
|
+
await splates.run(deps, ["lane", "show", "backlog", "--json"]);
|
|
28
|
+
expect(JSON.parse(testing.out())).toMatchObject({ lane: 0, name: "Backlog" });
|
|
29
|
+
});
|
package/lane.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APPROVAL,
|
|
3
|
+
BACKLOG_LANE,
|
|
4
|
+
laneAdd,
|
|
5
|
+
laneList,
|
|
6
|
+
laneRemove,
|
|
7
|
+
laneShow,
|
|
8
|
+
laneUpdate,
|
|
9
|
+
laneUpdated,
|
|
10
|
+
recordLaneRemove,
|
|
11
|
+
} from "@stringsync/splates-server/board";
|
|
12
|
+
import { refuseGroomer } from "@stringsync/splates-server/conversations";
|
|
13
|
+
import { UNTRIAGED } from "@stringsync/splates-server/plans";
|
|
14
|
+
import { refuseAgentQueue } from "@stringsync/splates-server/queue";
|
|
15
|
+
import { repository } from "@stringsync/splates-server/records";
|
|
16
|
+
import type { SplatesDeps } from "@stringsync/splates-server/server";
|
|
17
|
+
import { cli } from "webappwiz/cmd";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { position } from "./flags";
|
|
20
|
+
|
|
21
|
+
/** Backlog is lane zero, also addressable by its permanent name. */
|
|
22
|
+
const laneNumber = z.union([
|
|
23
|
+
z.literal("backlog").transform(() => BACKLOG_LANE),
|
|
24
|
+
z.coerce.number().int().nonnegative(),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** `splates lane`, which the root cli mounts after `splates task`. */
|
|
28
|
+
export const splatesLane = cli<SplatesDeps>("lane").description(
|
|
29
|
+
"lanes: named groups of tasks, for you to see related work together on the board; queue one whole with `splates lane queue`. Backlog (lane 0) is permanent and holds unmatched deferred work. Untriaged is groomed into lanes and cannot be queued whole",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
splatesLane
|
|
33
|
+
.command("list")
|
|
34
|
+
.use(repository({ needs: "read" }))
|
|
35
|
+
.description(
|
|
36
|
+
"every lane: its name, its queued tasks in order, the started tasks on it, its next task, and what blocks that in other lanes",
|
|
37
|
+
)
|
|
38
|
+
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
39
|
+
.action((opts, ctx) => laneList(ctx, { json: opts.json }));
|
|
40
|
+
|
|
41
|
+
splatesLane
|
|
42
|
+
.command("show")
|
|
43
|
+
.use(repository({ needs: "read" }))
|
|
44
|
+
.description(
|
|
45
|
+
"one lane step by step, and what its agent does next: start the next task, or wait for the one blocking it in another lane to land first; `untriaged` lists the tasks in no lane, to place in lanes",
|
|
46
|
+
)
|
|
47
|
+
.arg(
|
|
48
|
+
"lane",
|
|
49
|
+
z.union([z.literal(UNTRIAGED).transform(() => null), laneNumber], {
|
|
50
|
+
error: "pass a lane's number, backlog, or untriaged",
|
|
51
|
+
}),
|
|
52
|
+
{ description: "lane number, `backlog`, or `untriaged`" },
|
|
53
|
+
)
|
|
54
|
+
.option("json", z.boolean(), { default: false, description: "emit JSON" })
|
|
55
|
+
.action((opts, ctx) => laneShow(ctx, opts.lane, { json: opts.json }));
|
|
56
|
+
|
|
57
|
+
splatesLane
|
|
58
|
+
.command("add")
|
|
59
|
+
.use(repository({ needs: "tasks" }))
|
|
60
|
+
.description("start an empty lane, after the others")
|
|
61
|
+
.arg("name", z.string(), {
|
|
62
|
+
description: "what its tasks are for, like `Licensing`",
|
|
63
|
+
})
|
|
64
|
+
.action(async (opts, ctx) => {
|
|
65
|
+
await ctx.journal.record(
|
|
66
|
+
"lane add",
|
|
67
|
+
null,
|
|
68
|
+
() => laneAdd(ctx, opts.name),
|
|
69
|
+
(lane) => ({ lane: lane.id, name: lane.name }),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
splatesLane
|
|
74
|
+
.command("update")
|
|
75
|
+
.use(repository({ needs: "tasks" }))
|
|
76
|
+
.description("rename a lane, or move it among the others")
|
|
77
|
+
.arg("lane", laneNumber, {
|
|
78
|
+
description: "lane number",
|
|
79
|
+
})
|
|
80
|
+
.option("name", z.string().optional(), {
|
|
81
|
+
description: "what it is called from now on",
|
|
82
|
+
})
|
|
83
|
+
.option("position", position, {
|
|
84
|
+
description:
|
|
85
|
+
"where to move it among the lanes, 1 the first column on the board; the others close up around it",
|
|
86
|
+
})
|
|
87
|
+
.action(async (opts, ctx) => {
|
|
88
|
+
const change = { name: opts.name, position: opts.position };
|
|
89
|
+
await ctx.journal.record(
|
|
90
|
+
"lane update",
|
|
91
|
+
null,
|
|
92
|
+
() => laneUpdate(ctx, opts.lane, change),
|
|
93
|
+
(record) => laneUpdated(opts.lane, record, change),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
splatesLane
|
|
98
|
+
.command("queue")
|
|
99
|
+
.use(repository({ needs: "tasks" }))
|
|
100
|
+
.description(
|
|
101
|
+
"queue a lane whole: each of its tasks is queued as soon as nothing blocks it, now and as tasks land, until the lane is empty; a task you take out with `splates queue remove` stays out until you queue it again. Untriaged cannot be queued whole",
|
|
102
|
+
)
|
|
103
|
+
.arg("lane", laneNumber, {
|
|
104
|
+
description: "lane number",
|
|
105
|
+
})
|
|
106
|
+
.action(async (opts, ctx) => {
|
|
107
|
+
refuseAgentQueue(ctx.service, "lane queue");
|
|
108
|
+
await refuseGroomer(ctx.service, "lane queue");
|
|
109
|
+
const change = { queued: true };
|
|
110
|
+
await ctx.journal.record(
|
|
111
|
+
"lane update",
|
|
112
|
+
null,
|
|
113
|
+
() => laneUpdate(ctx, opts.lane, change),
|
|
114
|
+
(record) => laneUpdated(opts.lane, record, change),
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
splatesLane
|
|
119
|
+
.command("approve")
|
|
120
|
+
.use(repository({ needs: "tasks" }))
|
|
121
|
+
.description(
|
|
122
|
+
"let a queued lane queue more tasks on its own: it stops when its budget is spent, which starts at its tasks then plus 5, and this adds 10 to what is left; refused from an agent",
|
|
123
|
+
)
|
|
124
|
+
.arg("lane", laneNumber, {
|
|
125
|
+
description: "lane number",
|
|
126
|
+
})
|
|
127
|
+
.action(async (opts, ctx) => {
|
|
128
|
+
refuseAgentQueue(ctx.service, "lane approve");
|
|
129
|
+
const change = { approve: APPROVAL };
|
|
130
|
+
await ctx.journal.record(
|
|
131
|
+
"lane update",
|
|
132
|
+
null,
|
|
133
|
+
() => laneUpdate(ctx, opts.lane, change),
|
|
134
|
+
(record) => laneUpdated(opts.lane, record, change),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
splatesLane
|
|
139
|
+
.command("unqueue")
|
|
140
|
+
.use(repository({ needs: "tasks" }))
|
|
141
|
+
.description(
|
|
142
|
+
"stop queueing a lane whole, taking out of the queue each of its tasks that has yet to start; those under way go on",
|
|
143
|
+
)
|
|
144
|
+
.arg("lane", laneNumber, {
|
|
145
|
+
description: "lane number",
|
|
146
|
+
})
|
|
147
|
+
.action(async (opts, ctx) => {
|
|
148
|
+
refuseAgentQueue(ctx.service, "lane unqueue");
|
|
149
|
+
const change = { queued: false };
|
|
150
|
+
await ctx.journal.record(
|
|
151
|
+
"lane update",
|
|
152
|
+
null,
|
|
153
|
+
() => laneUpdate(ctx, opts.lane, change),
|
|
154
|
+
(record) => laneUpdated(opts.lane, record, change),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
splatesLane
|
|
159
|
+
.command("remove")
|
|
160
|
+
.use(repository({ needs: "tasks" }))
|
|
161
|
+
.description(
|
|
162
|
+
"remove a lane and its tasks, kept to restore; a lane whose last task merges goes on its own; refused from an agent, which asks with a permission card",
|
|
163
|
+
)
|
|
164
|
+
.arg("lane", laneNumber, {
|
|
165
|
+
description: "lane number",
|
|
166
|
+
})
|
|
167
|
+
.action((opts, ctx) => {
|
|
168
|
+
refuseAgentQueue(ctx.service, "lane remove");
|
|
169
|
+
return recordLaneRemove(ctx.journal, ctx.tasks, opts.lane, () =>
|
|
170
|
+
laneRemove(ctx, opts.lane),
|
|
171
|
+
);
|
|
172
|
+
});
|
package/notes.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { loadConfig } from "@stringsync/splates-server/config";
|
|
2
|
+
import { Notes } from "@stringsync/splates-server/notes";
|
|
3
|
+
import type { Repository } from "@stringsync/splates-server/records";
|
|
4
|
+
import type { SplatesDeps } from "@stringsync/splates-server/server";
|
|
5
|
+
|
|
6
|
+
/** Prints the descriptions in this checkout, or the project explicitly named. */
|
|
7
|
+
export async function notes(ctx: SplatesDeps & Repository): Promise<void> {
|
|
8
|
+
const root =
|
|
9
|
+
ctx.project === undefined
|
|
10
|
+
? (
|
|
11
|
+
await ctx.git.run(ctx.ps.cwd(), "rev-parse", "--show-toplevel")
|
|
12
|
+
).stdout.trim()
|
|
13
|
+
: ctx.git.root;
|
|
14
|
+
const config = await loadConfig(root, { fs: ctx.fs, git: ctx.git });
|
|
15
|
+
const found = await new Notes(ctx.fs).list(root, config);
|
|
16
|
+
for (const note of found) {
|
|
17
|
+
ctx.log.info(note.description);
|
|
18
|
+
}
|
|
19
|
+
}
|