opencode-codeops 2.2.1 → 2.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 +45 -0
- package/README.md +53 -0
- package/_shared/discovery.md +92 -0
- package/_shared/zero-ambiguity-gate.md +4 -0
- package/bin/lib/discovery-rules.d.mts +228 -0
- package/bin/lib/discovery-rules.mjs +1213 -0
- package/bin/lib/discovery-server.d.mts +62 -0
- package/bin/lib/discovery-server.mjs +755 -0
- package/bin/lib/discovery-store.d.mts +224 -0
- package/bin/lib/discovery-store.mjs +1189 -0
- package/bin/lib/discovery-tools.d.mts +66 -0
- package/bin/lib/discovery-tools.mjs +540 -0
- package/discovery-ui/dist/assets/app.js +107 -0
- package/discovery-ui/dist/index.html +17 -0
- package/package.json +14 -2
- package/plugin/index.ts +45 -4
- package/schemas/codeops-config.schema.json +8 -0
- package/scripts/__pycache__/codeops_plan.cpython-312.pyc +0 -0
- package/scripts/__pycache__/install_agents.cpython-312.pyc +0 -0
- package/scripts/build-discovery-ui.mjs +59 -0
- package/skills/analyze-project/SKILL.md +19 -0
- package/skills/make-plan/SKILL.md +7 -1
- package/skills/preflight/SKILL.md +4 -0
- package/skills/preflight/report-format.md +4 -0
- package/skills/setup-codeops/SKILL.md +3 -0
- package/skills/setup-codeops/scaffold.md +2 -1
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery store.
|
|
3
|
+
*
|
|
4
|
+
* Persists projects, plans, answers, and submissions as JSON files and owns
|
|
5
|
+
* every state transition. The server and the agent tools are its only callers;
|
|
6
|
+
* the rules module supplies validation and evaluation.
|
|
7
|
+
*
|
|
8
|
+
* Two invariants shape the implementation:
|
|
9
|
+
*
|
|
10
|
+
* - **Durability.** Every mutation re-reads the file, applies the change, and
|
|
11
|
+
* writes a temporary file that is renamed over the original. Mutations for
|
|
12
|
+
* one absolute path share a single process-wide queue, so two store
|
|
13
|
+
* instances in the same process cannot interleave a read-modify-write.
|
|
14
|
+
* - **Refusal over repair.** A file written by a newer schema version or a
|
|
15
|
+
* malformed file is never rewritten. The caller receives a typed
|
|
16
|
+
* `DiscoveryDataError` and can tell the user to update CodeOps.
|
|
17
|
+
*
|
|
18
|
+
* The module is Node-only: it reads and writes the user's data directory.
|
|
19
|
+
*
|
|
20
|
+
* @module discovery-store
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { randomBytes } from "node:crypto"
|
|
24
|
+
import {
|
|
25
|
+
chmodSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
realpathSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs"
|
|
35
|
+
import { homedir } from "node:os"
|
|
36
|
+
import { basename, dirname, join, resolve, sep } from "node:path"
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
DISCOVERY_SCHEMA_VERSION,
|
|
40
|
+
SLUG_PATTERN,
|
|
41
|
+
evaluatePlan,
|
|
42
|
+
groupReadiness,
|
|
43
|
+
planCounts,
|
|
44
|
+
questionFingerprint,
|
|
45
|
+
toAnswer,
|
|
46
|
+
validateAnswer,
|
|
47
|
+
validateDefer,
|
|
48
|
+
validatePublish,
|
|
49
|
+
} from "./discovery-rules.mjs"
|
|
50
|
+
|
|
51
|
+
/** Maximum length of a generated project slug. */
|
|
52
|
+
const PROJECT_SLUG_MAX = 40
|
|
53
|
+
|
|
54
|
+
/** Maximum length of a generated plan slug, including suffix and collision number. */
|
|
55
|
+
const PLAN_SLUG_MAX = 52
|
|
56
|
+
|
|
57
|
+
/** Maximum length of a stored note. */
|
|
58
|
+
const NOTES_MAX = 4000
|
|
59
|
+
|
|
60
|
+
/** One queue per absolute file path, shared by every store instance. */
|
|
61
|
+
const writeQueues = new Map()
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A refusal to read or write Discovery data.
|
|
65
|
+
*
|
|
66
|
+
* `code` is `newer-version` when the file was written by a newer CodeOps
|
|
67
|
+
* version, and `unreadable` when it cannot be parsed or resolved safely.
|
|
68
|
+
*/
|
|
69
|
+
export class DiscoveryDataError extends Error {
|
|
70
|
+
/**
|
|
71
|
+
* @param {"newer-version" | "unreadable"} code - The refusal reason.
|
|
72
|
+
* @param {string} message - A message safe to show the user.
|
|
73
|
+
*/
|
|
74
|
+
constructor(code, message) {
|
|
75
|
+
super(message)
|
|
76
|
+
this.name = "DiscoveryDataError"
|
|
77
|
+
this.code = code
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Expand a leading `~` to the user's home directory.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} value - The configured path.
|
|
85
|
+
* @param {string} home - The home directory to substitute.
|
|
86
|
+
* @returns {string} The expanded path.
|
|
87
|
+
*/
|
|
88
|
+
function expandHome(value, home) {
|
|
89
|
+
if (value === "~") return home
|
|
90
|
+
if (value.startsWith("~/")) return join(home, value.slice(2))
|
|
91
|
+
return value
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the Discovery data directory.
|
|
96
|
+
*
|
|
97
|
+
* The base is `$CODEOPS_DATA_DIR` when set, otherwise
|
|
98
|
+
* `~/.local/share/opencode/codeops`; the result always ends in `discovery`.
|
|
99
|
+
*
|
|
100
|
+
* @param {Record<string, string | undefined>} [env] - Environment variables.
|
|
101
|
+
* @param {string} [home] - Home directory, injectable for tests.
|
|
102
|
+
* @returns {string} The absolute Discovery data directory.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveDataDir(env = process.env, home = homedir()) {
|
|
105
|
+
const base = env?.CODEOPS_DATA_DIR
|
|
106
|
+
? expandHome(String(env.CODEOPS_DATA_DIR), home)
|
|
107
|
+
: join(home, ".local/share/opencode/codeops")
|
|
108
|
+
return join(base, "discovery")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True for an object created by `{}` or `JSON.parse`, false for arrays/null. */
|
|
112
|
+
function isPlainObject(value) {
|
|
113
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Lower-case and slugify one path segment, falling back to `project`. */
|
|
117
|
+
function slugify(value, max) {
|
|
118
|
+
const slug = String(value)
|
|
119
|
+
.toLowerCase()
|
|
120
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
121
|
+
.replace(/^-+|-+$/g, "")
|
|
122
|
+
.slice(0, max)
|
|
123
|
+
.replace(/-+$/g, "")
|
|
124
|
+
return slug.length > 0 ? slug : "project"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Derive the base plan slug from the artifact's last segment and the kind. */
|
|
128
|
+
function planSlugBase(artifact, kind) {
|
|
129
|
+
const segment = String(artifact).split("/").filter(Boolean).pop() ?? "plan"
|
|
130
|
+
let base = slugify(segment, PROJECT_SLUG_MAX)
|
|
131
|
+
if (kind !== "plan" && base !== kind) base = `${base}-${kind}`
|
|
132
|
+
return base.slice(0, PLAN_SLUG_MAX).replace(/-+$/g, "") || "plan"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Create a directory with owner-only permissions and tighten an existing one. */
|
|
136
|
+
function ensureDirectory(directory, mode = 0o700) {
|
|
137
|
+
mkdirSync(directory, { recursive: true, mode })
|
|
138
|
+
try {
|
|
139
|
+
chmodSync(directory, mode)
|
|
140
|
+
} catch {
|
|
141
|
+
// Best effort: some file systems do not support POSIX modes.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when a process id is still running on this machine. */
|
|
146
|
+
function isPidAlive(pid) {
|
|
147
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
148
|
+
try {
|
|
149
|
+
process.kill(pid, 0)
|
|
150
|
+
return true
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return error?.code === "EPERM"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Write one JSON record through a temporary file and an atomic rename. */
|
|
157
|
+
function writeJson(file, record) {
|
|
158
|
+
ensureDirectory(dirname(file))
|
|
159
|
+
const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
162
|
+
renameSync(temporary, file)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
try {
|
|
165
|
+
unlinkSync(temporary)
|
|
166
|
+
} catch {
|
|
167
|
+
// The temporary file was never created or is already gone.
|
|
168
|
+
}
|
|
169
|
+
throw error
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run one task inside the process-wide queue for a file path.
|
|
175
|
+
*
|
|
176
|
+
* The task always runs after the previous task for the same path settles,
|
|
177
|
+
* whether it succeeded or failed. The returned promise carries the task's own
|
|
178
|
+
* result or error.
|
|
179
|
+
*
|
|
180
|
+
* @template T
|
|
181
|
+
* @param {string} file - The absolute file path.
|
|
182
|
+
* @param {() => Promise<T> | T} task - The critical section.
|
|
183
|
+
* @returns {Promise<T>} The task's result.
|
|
184
|
+
*/
|
|
185
|
+
function withFile(file, task) {
|
|
186
|
+
const previous = writeQueues.get(file) ?? Promise.resolve()
|
|
187
|
+
const run = previous.then(task, task)
|
|
188
|
+
const tracked = run.catch(() => {})
|
|
189
|
+
writeQueues.set(file, tracked)
|
|
190
|
+
tracked.finally(() => {
|
|
191
|
+
if (writeQueues.get(file) === tracked) writeQueues.delete(file)
|
|
192
|
+
})
|
|
193
|
+
return run
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Create the discovery store over one data directory.
|
|
198
|
+
*
|
|
199
|
+
* The data directory is created with mode `0700` if needed and tightened if it
|
|
200
|
+
* already exists. All other methods are asynchronous so the write queue can
|
|
201
|
+
* serialize them.
|
|
202
|
+
*
|
|
203
|
+
* @param {{ dataDir: string, now?: () => Date }} options - The data directory and an
|
|
204
|
+
* optional clock, injectable for tests.
|
|
205
|
+
* @returns {object} The store API.
|
|
206
|
+
*/
|
|
207
|
+
export function createDiscoveryStore(options) {
|
|
208
|
+
const dataDir = resolve(options.dataDir)
|
|
209
|
+
const now = typeof options.now === "function" ? options.now : () => new Date()
|
|
210
|
+
const timestamp = () => now().toISOString()
|
|
211
|
+
ensureDirectory(dataDir)
|
|
212
|
+
|
|
213
|
+
const projectsRoot = join(dataDir, "projects")
|
|
214
|
+
|
|
215
|
+
/** Absolute project directory, refusing a path outside the data directory. */
|
|
216
|
+
function projectDirectory(projectSlug) {
|
|
217
|
+
if (typeof projectSlug !== "string" || !SLUG_PATTERN.test(projectSlug)) {
|
|
218
|
+
throw new DiscoveryDataError("unreadable", "invalid project slug")
|
|
219
|
+
}
|
|
220
|
+
const directory = resolve(projectsRoot, projectSlug)
|
|
221
|
+
if (!directory.startsWith(projectsRoot + sep)) {
|
|
222
|
+
throw new DiscoveryDataError("unreadable", "path outside the Discovery data directory")
|
|
223
|
+
}
|
|
224
|
+
return directory
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Absolute plan file, refusing a path outside the project directory. */
|
|
228
|
+
function planFile(projectSlug, planSlug) {
|
|
229
|
+
if (typeof planSlug !== "string" || !SLUG_PATTERN.test(planSlug)) {
|
|
230
|
+
throw new DiscoveryDataError("unreadable", "invalid plan slug")
|
|
231
|
+
}
|
|
232
|
+
const directory = projectDirectory(projectSlug)
|
|
233
|
+
const file = resolve(directory, "plans", `${planSlug}.json`)
|
|
234
|
+
if (!file.startsWith(join(directory, "plans") + sep)) {
|
|
235
|
+
throw new DiscoveryDataError("unreadable", "path outside the Discovery data directory")
|
|
236
|
+
}
|
|
237
|
+
return file
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The project record file. */
|
|
241
|
+
function projectFile(projectSlug) {
|
|
242
|
+
return join(projectDirectory(projectSlug), "project.json")
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Parse one JSON file, refusing newer or malformed data. */
|
|
246
|
+
function readJson(file) {
|
|
247
|
+
if (!existsSync(file)) return null
|
|
248
|
+
let parsed
|
|
249
|
+
try {
|
|
250
|
+
parsed = JSON.parse(readFileSync(file, "utf8"))
|
|
251
|
+
} catch {
|
|
252
|
+
throw new DiscoveryDataError("unreadable", `unreadable Discovery data: ${basename(file)}`)
|
|
253
|
+
}
|
|
254
|
+
if (!isPlainObject(parsed)) {
|
|
255
|
+
throw new DiscoveryDataError("unreadable", `unreadable Discovery data: ${basename(file)}`)
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
typeof parsed.schemaVersion === "number" &&
|
|
259
|
+
parsed.schemaVersion > DISCOVERY_SCHEMA_VERSION
|
|
260
|
+
) {
|
|
261
|
+
throw new DiscoveryDataError(
|
|
262
|
+
"newer-version",
|
|
263
|
+
"written by a newer CodeOps version — update CodeOps",
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
return parsed
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Read every project record; a broken project is skipped, never rewritten. */
|
|
270
|
+
function readProjectRecords() {
|
|
271
|
+
if (!existsSync(projectsRoot)) return []
|
|
272
|
+
const records = []
|
|
273
|
+
for (const entry of readdirSync(projectsRoot, { withFileTypes: true })) {
|
|
274
|
+
if (!entry.isDirectory()) continue
|
|
275
|
+
try {
|
|
276
|
+
const record = readJson(join(projectsRoot, entry.name, "project.json"))
|
|
277
|
+
if (!record) continue
|
|
278
|
+
if (record.slug !== entry.name || !SLUG_PATTERN.test(record.slug)) continue
|
|
279
|
+
records.push(record)
|
|
280
|
+
} catch {
|
|
281
|
+
// A broken project stays unavailable; other projects keep working.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return records.sort((left, right) => String(left.slug).localeCompare(String(right.slug)))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Read every plan record of a project, optionally skipping refused files. */
|
|
288
|
+
function readPlanRecords(projectSlug, { skipRefused = true } = {}) {
|
|
289
|
+
const directory = join(projectDirectory(projectSlug), "plans")
|
|
290
|
+
if (!existsSync(directory)) return []
|
|
291
|
+
const records = []
|
|
292
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
293
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue
|
|
294
|
+
try {
|
|
295
|
+
const record = readJson(join(directory, entry.name))
|
|
296
|
+
if (record) records.push(record)
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (!skipRefused) throw error
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return records.sort((left, right) => String(left.slug).localeCompare(String(right.slug)))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Pick a unique project slug for a directory name. */
|
|
305
|
+
function uniqueProjectSlug(base, records) {
|
|
306
|
+
const taken = new Set(records.map((record) => record.slug))
|
|
307
|
+
let slug = base
|
|
308
|
+
let counter = 2
|
|
309
|
+
while (taken.has(slug)) {
|
|
310
|
+
const suffix = `-${counter}`
|
|
311
|
+
slug = `${base.slice(0, Math.max(1, PROJECT_SLUG_MAX - suffix.length))}${suffix}`
|
|
312
|
+
counter += 1
|
|
313
|
+
}
|
|
314
|
+
return slug
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Pick a unique plan slug inside one project. */
|
|
318
|
+
function uniquePlanSlug(base, records) {
|
|
319
|
+
const taken = new Set(records.map((record) => record.slug))
|
|
320
|
+
let slug = base
|
|
321
|
+
let counter = 2
|
|
322
|
+
while (taken.has(slug)) {
|
|
323
|
+
const suffix = `-${counter}`
|
|
324
|
+
slug = `${base.slice(0, Math.max(1, PLAN_SLUG_MAX - suffix.length))}${suffix}`
|
|
325
|
+
counter += 1
|
|
326
|
+
}
|
|
327
|
+
return slug
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Build the evaluation state from a plan record. */
|
|
331
|
+
function stateOf(record) {
|
|
332
|
+
return {
|
|
333
|
+
questions: Object.fromEntries(
|
|
334
|
+
record.questions.map((question) => [
|
|
335
|
+
question.key,
|
|
336
|
+
{ ...question.state, revisedAt: question.revisedAt },
|
|
337
|
+
]),
|
|
338
|
+
),
|
|
339
|
+
submittedGroups: record.groups
|
|
340
|
+
.filter((group) => Boolean(group.submittedAt))
|
|
341
|
+
.map((group) => group.key),
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The default state of a new question. */
|
|
346
|
+
function defaultQuestionState() {
|
|
347
|
+
return {
|
|
348
|
+
answer: null,
|
|
349
|
+
notes: "",
|
|
350
|
+
defer: null,
|
|
351
|
+
needsDiscussion: false,
|
|
352
|
+
proposalDismissed: false,
|
|
353
|
+
status: "open",
|
|
354
|
+
changed: false,
|
|
355
|
+
recordedAs: null,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Build a new plan record from a validated payload. */
|
|
360
|
+
function newPlanRecord(slug, plan, sessionID, at) {
|
|
361
|
+
return {
|
|
362
|
+
schemaVersion: DISCOVERY_SCHEMA_VERSION,
|
|
363
|
+
slug,
|
|
364
|
+
key: `${plan.kind}:${plan.artifact}`,
|
|
365
|
+
kind: plan.kind,
|
|
366
|
+
artifact: plan.artifact,
|
|
367
|
+
title: plan.title,
|
|
368
|
+
owner: { sessionID },
|
|
369
|
+
orphaned: false,
|
|
370
|
+
archived: false,
|
|
371
|
+
unarchivedAt: null,
|
|
372
|
+
revision: 1,
|
|
373
|
+
createdAt: at,
|
|
374
|
+
updatedAt: at,
|
|
375
|
+
groups: plan.groups.map((group) => ({ ...group, submittedAt: null })),
|
|
376
|
+
questions: plan.questions.map((question) => ({
|
|
377
|
+
...question,
|
|
378
|
+
fingerprint: questionFingerprint(question),
|
|
379
|
+
revisedAt: 1,
|
|
380
|
+
state: defaultQuestionState(),
|
|
381
|
+
})),
|
|
382
|
+
submissions: [],
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Merge a validated payload into an existing plan record. */
|
|
387
|
+
function mergePlanRecord(existing, plan, sessionID, takeover, at) {
|
|
388
|
+
const revision = existing.revision + 1
|
|
389
|
+
const warnings = []
|
|
390
|
+
const oldByKey = new Map(existing.questions.map((question) => [question.key, question]))
|
|
391
|
+
const incomingKeys = new Set(plan.questions.map((question) => question.key))
|
|
392
|
+
const questions = []
|
|
393
|
+
|
|
394
|
+
for (const [index, incoming] of plan.questions.entries()) {
|
|
395
|
+
const old = oldByKey.get(incoming.key)
|
|
396
|
+
if (!old) {
|
|
397
|
+
questions.push({
|
|
398
|
+
...incoming,
|
|
399
|
+
fingerprint: questionFingerprint(incoming),
|
|
400
|
+
revisedAt: revision,
|
|
401
|
+
state: defaultQuestionState(),
|
|
402
|
+
})
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
if (old.type !== incoming.type) {
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
errors: [
|
|
409
|
+
`questions[${index}].type: ${incoming.key} changes type; use a new key for a new decision`,
|
|
410
|
+
],
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const fingerprint = questionFingerprint(incoming)
|
|
414
|
+
if (fingerprint === old.fingerprint) {
|
|
415
|
+
questions.push({
|
|
416
|
+
...old,
|
|
417
|
+
...incoming,
|
|
418
|
+
fingerprint: old.fingerprint,
|
|
419
|
+
revisedAt: old.revisedAt,
|
|
420
|
+
state: old.state,
|
|
421
|
+
})
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
if (old.state.status === "open") {
|
|
425
|
+
questions.push({
|
|
426
|
+
...incoming,
|
|
427
|
+
fingerprint,
|
|
428
|
+
revisedAt: revision,
|
|
429
|
+
state: { ...old.state, changed: true },
|
|
430
|
+
})
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
warnings.push(
|
|
434
|
+
`${incoming.key} is ${old.state.status}; use a new key for a new decision`,
|
|
435
|
+
)
|
|
436
|
+
questions.push(old)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
for (const question of existing.questions) {
|
|
440
|
+
if (incomingKeys.has(question.key)) continue
|
|
441
|
+
if (question.state.status === "open") {
|
|
442
|
+
questions.push({ ...question, state: { ...question.state, status: "retired" } })
|
|
443
|
+
} else {
|
|
444
|
+
if (question.state.status === "submitted" || question.state.status === "recorded") {
|
|
445
|
+
warnings.push(
|
|
446
|
+
`${question.key} is ${question.state.status}; use a new key for a new decision`,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
questions.push(question)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const oldGroups = new Map(existing.groups.map((group) => [group.key, group]))
|
|
454
|
+
const ownerId = existing.owner?.sessionID ?? null
|
|
455
|
+
const nextOwner =
|
|
456
|
+
ownerId === null || existing.orphaned || takeover ? { sessionID } : existing.owner
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
ok: true,
|
|
460
|
+
warnings,
|
|
461
|
+
plan: {
|
|
462
|
+
...existing,
|
|
463
|
+
title: plan.title,
|
|
464
|
+
owner: nextOwner,
|
|
465
|
+
orphaned: false,
|
|
466
|
+
archived: false,
|
|
467
|
+
unarchivedAt: existing.archived ? at : existing.unarchivedAt ?? null,
|
|
468
|
+
revision,
|
|
469
|
+
updatedAt: at,
|
|
470
|
+
groups: plan.groups.map((group) => ({
|
|
471
|
+
...group,
|
|
472
|
+
submittedAt: oldGroups.get(group.key)?.submittedAt ?? null,
|
|
473
|
+
})),
|
|
474
|
+
questions,
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Release claims left behind by processes that no longer run.
|
|
481
|
+
*
|
|
482
|
+
* A claim is a hint that a wake is in flight; a dead claim must never hide
|
|
483
|
+
* pending work. Refused files are left untouched, exactly like every other
|
|
484
|
+
* reader.
|
|
485
|
+
*/
|
|
486
|
+
function sweepDeadClaims() {
|
|
487
|
+
for (const project of readProjectRecords()) {
|
|
488
|
+
for (const summary of readPlanRecords(project.slug)) {
|
|
489
|
+
try {
|
|
490
|
+
const file = planFile(project.slug, summary.slug)
|
|
491
|
+
const record = readJson(file)
|
|
492
|
+
if (!record) continue
|
|
493
|
+
let changed = false
|
|
494
|
+
for (const submission of record.submissions) {
|
|
495
|
+
if (submission.claimedBy && !isPidAlive(submission.claimedBy.pid)) {
|
|
496
|
+
submission.claimedBy = null
|
|
497
|
+
changed = true
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (changed) {
|
|
501
|
+
record.updatedAt = timestamp()
|
|
502
|
+
writeJson(file, record)
|
|
503
|
+
}
|
|
504
|
+
} catch {
|
|
505
|
+
// A refused plan stays unavailable; the sweep never rewrites it.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
sweepDeadClaims()
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
/**
|
|
515
|
+
* Register a checkout directory, or refresh its branch and repository.
|
|
516
|
+
*
|
|
517
|
+
* @param {{ directory: string, branch: string | null,
|
|
518
|
+
* repository: { id: string, canonical: string } }} input - Project identity.
|
|
519
|
+
* @returns {Promise<object>} The project record.
|
|
520
|
+
*/
|
|
521
|
+
async registerProject(input) {
|
|
522
|
+
const directory = resolve(String(input.directory))
|
|
523
|
+
let real = directory
|
|
524
|
+
try {
|
|
525
|
+
real = realpathSync(directory)
|
|
526
|
+
} catch {
|
|
527
|
+
// Registration may race a directory that no longer exists; keep the path.
|
|
528
|
+
}
|
|
529
|
+
const records = readProjectRecords()
|
|
530
|
+
const repository = {
|
|
531
|
+
id: String(input.repository?.id ?? real),
|
|
532
|
+
canonical: String(input.repository?.canonical ?? real),
|
|
533
|
+
name: basename(String(input.repository?.canonical ?? real)),
|
|
534
|
+
}
|
|
535
|
+
const existing = records.find((record) => record.directory === real)
|
|
536
|
+
if (existing) {
|
|
537
|
+
existing.branch = input.branch ?? null
|
|
538
|
+
existing.repository = repository
|
|
539
|
+
existing.updatedAt = timestamp()
|
|
540
|
+
writeJson(projectFile(existing.slug), existing)
|
|
541
|
+
return existing
|
|
542
|
+
}
|
|
543
|
+
const slug = uniqueProjectSlug(slugify(basename(real), PROJECT_SLUG_MAX), records)
|
|
544
|
+
const record = {
|
|
545
|
+
schemaVersion: DISCOVERY_SCHEMA_VERSION,
|
|
546
|
+
slug,
|
|
547
|
+
directory: real,
|
|
548
|
+
name: basename(real),
|
|
549
|
+
branch: input.branch ?? null,
|
|
550
|
+
repository,
|
|
551
|
+
createdAt: timestamp(),
|
|
552
|
+
updatedAt: timestamp(),
|
|
553
|
+
}
|
|
554
|
+
writeJson(projectFile(slug), record)
|
|
555
|
+
return record
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* List every readable project with its aggregated question counts.
|
|
560
|
+
*
|
|
561
|
+
* @returns {Promise<object[]>} Project summaries.
|
|
562
|
+
*/
|
|
563
|
+
async listProjects() {
|
|
564
|
+
const summaries = []
|
|
565
|
+
for (const project of readProjectRecords()) {
|
|
566
|
+
const plans = readPlanRecords(project.slug)
|
|
567
|
+
const counts = {
|
|
568
|
+
plans: plans.length,
|
|
569
|
+
open: 0,
|
|
570
|
+
answered: 0,
|
|
571
|
+
deferred: 0,
|
|
572
|
+
discussion: 0,
|
|
573
|
+
submitted: 0,
|
|
574
|
+
recorded: 0,
|
|
575
|
+
}
|
|
576
|
+
for (const record of plans) {
|
|
577
|
+
const state = stateOf(record)
|
|
578
|
+
const plan = planCounts(record, state, evaluatePlan(record, state))
|
|
579
|
+
for (const key of Object.keys(counts)) {
|
|
580
|
+
if (key !== "plans") counts[key] += plan[key]
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
summaries.push({ ...project, counts })
|
|
584
|
+
}
|
|
585
|
+
return summaries
|
|
586
|
+
},
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Read one project record.
|
|
590
|
+
*
|
|
591
|
+
* @param {string} projectSlug - The project slug.
|
|
592
|
+
* @returns {Promise<object | null>} The record, or null when missing.
|
|
593
|
+
*/
|
|
594
|
+
async getProject(projectSlug) {
|
|
595
|
+
return readJson(projectFile(projectSlug))
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Find a project by its checkout directory.
|
|
600
|
+
*
|
|
601
|
+
* @param {string} directory - The checkout directory.
|
|
602
|
+
* @returns {Promise<object | null>} The record, or null when missing.
|
|
603
|
+
*/
|
|
604
|
+
async findProjectByDirectory(directory) {
|
|
605
|
+
let real = resolve(String(directory))
|
|
606
|
+
try {
|
|
607
|
+
real = realpathSync(real)
|
|
608
|
+
} catch {
|
|
609
|
+
// Fall back to the resolved path.
|
|
610
|
+
}
|
|
611
|
+
return readProjectRecords().find((record) => record.directory === real) ?? null
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* List the plans of a project.
|
|
616
|
+
*
|
|
617
|
+
* @param {string} projectSlug - The project slug.
|
|
618
|
+
* @returns {Promise<object[]>} Plan summaries, archived plans included.
|
|
619
|
+
*/
|
|
620
|
+
async listPlans(projectSlug) {
|
|
621
|
+
return readPlanRecords(projectSlug).map((record) => {
|
|
622
|
+
const state = stateOf(record)
|
|
623
|
+
return {
|
|
624
|
+
slug: record.slug,
|
|
625
|
+
kind: record.kind,
|
|
626
|
+
artifact: record.artifact,
|
|
627
|
+
title: record.title,
|
|
628
|
+
revision: record.revision,
|
|
629
|
+
archived: record.archived,
|
|
630
|
+
orphaned: record.orphaned,
|
|
631
|
+
updatedAt: record.updatedAt,
|
|
632
|
+
counts: planCounts(record, state, evaluatePlan(record, state)),
|
|
633
|
+
}
|
|
634
|
+
})
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Read one plan record.
|
|
639
|
+
*
|
|
640
|
+
* @param {string} projectSlug - The project slug.
|
|
641
|
+
* @param {string} planSlug - The plan slug.
|
|
642
|
+
* @returns {Promise<object | null>} The record, or null when missing.
|
|
643
|
+
* @throws {DiscoveryDataError} When the file must be refused.
|
|
644
|
+
*/
|
|
645
|
+
async getPlan(projectSlug, planSlug) {
|
|
646
|
+
return readJson(planFile(projectSlug, planSlug))
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Find a plan by kind and artifact path.
|
|
651
|
+
*
|
|
652
|
+
* @param {string} projectSlug - The project slug.
|
|
653
|
+
* @param {string} kind - The plan kind.
|
|
654
|
+
* @param {string} artifact - The artifact path.
|
|
655
|
+
* @returns {Promise<object | null>} The matching record, or null.
|
|
656
|
+
*/
|
|
657
|
+
async findPlan(projectSlug, kind, artifact) {
|
|
658
|
+
return (
|
|
659
|
+
readPlanRecords(projectSlug, { skipRefused: false }).find(
|
|
660
|
+
(record) => record.key === `${kind}:${artifact}`,
|
|
661
|
+
) ?? null
|
|
662
|
+
)
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Publish a plan, or a new revision of it.
|
|
667
|
+
*
|
|
668
|
+
* @param {string} projectSlug - The project slug.
|
|
669
|
+
* @param {unknown} payload - The publish payload.
|
|
670
|
+
* @param {string} sessionID - The owning session.
|
|
671
|
+
* @returns {Promise<object>} The publish result union.
|
|
672
|
+
*/
|
|
673
|
+
async publish(projectSlug, payload, sessionID) {
|
|
674
|
+
const project = readJson(projectFile(projectSlug))
|
|
675
|
+
if (!project) {
|
|
676
|
+
return { ok: false, reason: "invalid", errors: ["project not found"] }
|
|
677
|
+
}
|
|
678
|
+
const validated = validatePublish(payload)
|
|
679
|
+
if (!validated.ok) return { ok: false, reason: "invalid", errors: validated.errors }
|
|
680
|
+
const plan = validated.plan
|
|
681
|
+
const takeover = isPlainObject(payload) && payload.takeover === true
|
|
682
|
+
const key = `${plan.kind}:${plan.artifact}`
|
|
683
|
+
|
|
684
|
+
// Publishing is serialized per project, not per target file: a new plan
|
|
685
|
+
// chooses its slug by scanning its siblings, so two concurrent first
|
|
686
|
+
// publishes of different keys could otherwise pick the same slug.
|
|
687
|
+
const lock = join(projectDirectory(projectSlug), ".publish")
|
|
688
|
+
return withFile(lock, () => {
|
|
689
|
+
const records = readPlanRecords(projectSlug, { skipRefused: false })
|
|
690
|
+
const existing = records.find((record) => record.key === key) ?? null
|
|
691
|
+
const slug = existing
|
|
692
|
+
? existing.slug
|
|
693
|
+
: uniquePlanSlug(planSlugBase(plan.artifact, plan.kind), records)
|
|
694
|
+
const file = planFile(projectSlug, slug)
|
|
695
|
+
const current = readJson(file)
|
|
696
|
+
if (current && current.key !== key) {
|
|
697
|
+
return {
|
|
698
|
+
ok: false,
|
|
699
|
+
reason: "invalid",
|
|
700
|
+
errors: [`${key}: a different plan already owns the slug "${slug}"`],
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const ownerId = current?.owner?.sessionID ?? null
|
|
704
|
+
if (current && ownerId !== null && !current.orphaned && ownerId !== sessionID && !takeover) {
|
|
705
|
+
return { ok: false, reason: "owned", owner: ownerId }
|
|
706
|
+
}
|
|
707
|
+
const at = timestamp()
|
|
708
|
+
if (!current) {
|
|
709
|
+
const record = newPlanRecord(slug, plan, sessionID, at)
|
|
710
|
+
writeJson(file, record)
|
|
711
|
+
return { ok: true, plan: record, warnings: [] }
|
|
712
|
+
}
|
|
713
|
+
const merged = mergePlanRecord(current, plan, sessionID, takeover, at)
|
|
714
|
+
if (!merged.ok) return { ok: false, reason: "invalid", errors: merged.errors }
|
|
715
|
+
writeJson(file, merged.plan)
|
|
716
|
+
return { ok: true, plan: merged.plan, warnings: merged.warnings }
|
|
717
|
+
})
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Save one answer, deferral, notes, discussion marker, or proposal action.
|
|
722
|
+
*
|
|
723
|
+
* @param {string} projectSlug - The project slug.
|
|
724
|
+
* @param {string} planSlug - The plan slug.
|
|
725
|
+
* @param {string} key - The question key.
|
|
726
|
+
* @param {object} patch - The answer patch.
|
|
727
|
+
* @param {number} baseRevision - The revision the page rendered.
|
|
728
|
+
* @returns {Promise<object>} The save result union.
|
|
729
|
+
*/
|
|
730
|
+
async saveAnswer(projectSlug, planSlug, key, patch, baseRevision) {
|
|
731
|
+
const file = planFile(projectSlug, planSlug)
|
|
732
|
+
return withFile(file, () => {
|
|
733
|
+
const record = readJson(file)
|
|
734
|
+
if (!record) return { ok: false, reason: "not-found", message: "plan not found" }
|
|
735
|
+
const question = record.questions.find((entry) => entry.key === key)
|
|
736
|
+
if (!question) return { ok: false, reason: "not-found", message: "question not found" }
|
|
737
|
+
if (question.state.status !== "open") {
|
|
738
|
+
return { ok: false, reason: "read-only", message: "this question is read-only" }
|
|
739
|
+
}
|
|
740
|
+
const state = stateOf(record)
|
|
741
|
+
if (evaluatePlan(record, state).lockedGroups.has(question.group)) {
|
|
742
|
+
return { ok: false, reason: "locked", message: "submit the prerequisite group first" }
|
|
743
|
+
}
|
|
744
|
+
if (typeof baseRevision === "number" && baseRevision < question.revisedAt) {
|
|
745
|
+
return { ok: false, reason: "stale", message: "this question changed — re-check it" }
|
|
746
|
+
}
|
|
747
|
+
if (!isPlainObject(patch)) {
|
|
748
|
+
return { ok: false, reason: "invalid", message: "expected an answer patch" }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const next = { ...question.state }
|
|
752
|
+
if (patch.reconfirm === true) {
|
|
753
|
+
const check = next.answer === null ? { ok: true } : validateAnswer(question, next.answer)
|
|
754
|
+
if (!check.ok) {
|
|
755
|
+
next.answer = null
|
|
756
|
+
next.changed = true
|
|
757
|
+
question.state = next
|
|
758
|
+
record.updatedAt = timestamp()
|
|
759
|
+
writeJson(file, record)
|
|
760
|
+
return { ok: false, reason: "invalid", message: check.error }
|
|
761
|
+
}
|
|
762
|
+
next.changed = false
|
|
763
|
+
}
|
|
764
|
+
if (patch.confirmProposal === true) {
|
|
765
|
+
if (!question.proposal) {
|
|
766
|
+
return { ok: false, reason: "invalid", message: "this question has no proposal" }
|
|
767
|
+
}
|
|
768
|
+
next.answer = toAnswer(question, question.proposal.answer)
|
|
769
|
+
next.changed = false
|
|
770
|
+
}
|
|
771
|
+
if (patch.dismissProposal === true) next.proposalDismissed = true
|
|
772
|
+
if ("answer" in patch) {
|
|
773
|
+
if (patch.answer === null) {
|
|
774
|
+
next.answer = null
|
|
775
|
+
} else {
|
|
776
|
+
const check = validateAnswer(question, patch.answer)
|
|
777
|
+
if (!check.ok) return { ok: false, reason: "invalid", message: check.error }
|
|
778
|
+
next.answer = patch.answer
|
|
779
|
+
}
|
|
780
|
+
next.changed = false
|
|
781
|
+
}
|
|
782
|
+
if ("defer" in patch) {
|
|
783
|
+
if (patch.defer === null) {
|
|
784
|
+
next.defer = null
|
|
785
|
+
} else {
|
|
786
|
+
const check = validateDefer(patch.defer)
|
|
787
|
+
if (!check.ok) return { ok: false, reason: "invalid", message: check.error }
|
|
788
|
+
next.defer = patch.defer
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
if ("notes" in patch) {
|
|
792
|
+
if (typeof patch.notes !== "string") {
|
|
793
|
+
return { ok: false, reason: "invalid", message: "notes must be text" }
|
|
794
|
+
}
|
|
795
|
+
if (patch.notes.length > NOTES_MAX) {
|
|
796
|
+
return { ok: false, reason: "invalid", message: "notes are too long" }
|
|
797
|
+
}
|
|
798
|
+
next.notes = patch.notes.trim()
|
|
799
|
+
}
|
|
800
|
+
if ("needsDiscussion" in patch) {
|
|
801
|
+
next.needsDiscussion = patch.needsDiscussion === true
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
question.state = next
|
|
805
|
+
record.updatedAt = timestamp()
|
|
806
|
+
writeJson(file, record)
|
|
807
|
+
return { ok: true, plan: record }
|
|
808
|
+
})
|
|
809
|
+
},
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Submit one ready group, or every ready group.
|
|
813
|
+
*
|
|
814
|
+
* @param {string} projectSlug - The project slug.
|
|
815
|
+
* @param {string} planSlug - The plan slug.
|
|
816
|
+
* @param {string | null} group - One group key, or null for every ready group.
|
|
817
|
+
* @param {number | null} baseRevision - The revision the page rendered, or null.
|
|
818
|
+
* @returns {Promise<object>} The submit result union.
|
|
819
|
+
*/
|
|
820
|
+
async submit(projectSlug, planSlug, group, baseRevision) {
|
|
821
|
+
const file = planFile(projectSlug, planSlug)
|
|
822
|
+
return withFile(file, () => {
|
|
823
|
+
const record = readJson(file)
|
|
824
|
+
if (!record) return { ok: false, reason: "not-found" }
|
|
825
|
+
const state = stateOf(record)
|
|
826
|
+
const evaluation = evaluatePlan(record, state)
|
|
827
|
+
const keys = group === null ? record.groups.map((entry) => entry.key) : [group]
|
|
828
|
+
const chosen = []
|
|
829
|
+
const skipped = []
|
|
830
|
+
for (const key of keys) {
|
|
831
|
+
const readiness = groupReadiness(record, state, evaluation, key)
|
|
832
|
+
if (group !== null && !readiness.ready) {
|
|
833
|
+
return { ok: false, reason: "not-ready", missing: readiness.missing }
|
|
834
|
+
}
|
|
835
|
+
if (readiness.ready) chosen.push(key)
|
|
836
|
+
else skipped.push({ group: key, missing: readiness.missing })
|
|
837
|
+
}
|
|
838
|
+
if (chosen.length === 0) return { ok: false, reason: "nothing-ready" }
|
|
839
|
+
if (typeof baseRevision === "number") {
|
|
840
|
+
const stale = record.questions.some(
|
|
841
|
+
(question) =>
|
|
842
|
+
chosen.includes(question.group) &&
|
|
843
|
+
question.state.status === "open" &&
|
|
844
|
+
question.revisedAt > baseRevision,
|
|
845
|
+
)
|
|
846
|
+
if (stale) return { ok: false, reason: "stale" }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const submitted = []
|
|
850
|
+
for (const question of record.questions) {
|
|
851
|
+
if (!chosen.includes(question.group)) continue
|
|
852
|
+
if (question.state.status !== "open") continue
|
|
853
|
+
const entry = evaluation.questions.get(question.key)
|
|
854
|
+
if (entry?.visible !== true) continue
|
|
855
|
+
if (entry.effective !== null) {
|
|
856
|
+
question.state.status = "submitted"
|
|
857
|
+
submitted.push({
|
|
858
|
+
key: question.key,
|
|
859
|
+
title: question.title,
|
|
860
|
+
answer: entry.effective,
|
|
861
|
+
source: entry.source ?? "user",
|
|
862
|
+
impliedBy: entry.source === "implied" ? entry.impliedBy : [],
|
|
863
|
+
overridesImplied: entry.overridesImplied,
|
|
864
|
+
notes: question.state.notes ?? "",
|
|
865
|
+
defer: question.state.defer ?? null,
|
|
866
|
+
needsDiscussion: false,
|
|
867
|
+
})
|
|
868
|
+
} else if (question.state.defer && validateDefer(question.state.defer).ok) {
|
|
869
|
+
question.state.status = "submitted"
|
|
870
|
+
submitted.push({
|
|
871
|
+
key: question.key,
|
|
872
|
+
title: question.title,
|
|
873
|
+
answer: null,
|
|
874
|
+
source: null,
|
|
875
|
+
impliedBy: [],
|
|
876
|
+
overridesImplied: false,
|
|
877
|
+
notes: question.state.notes ?? "",
|
|
878
|
+
defer: question.state.defer,
|
|
879
|
+
needsDiscussion: false,
|
|
880
|
+
})
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const at = timestamp()
|
|
885
|
+
for (const key of chosen) {
|
|
886
|
+
const entry = record.groups.find((candidate) => candidate.key === key)
|
|
887
|
+
if (entry && !entry.submittedAt) entry.submittedAt = at
|
|
888
|
+
}
|
|
889
|
+
const submission = {
|
|
890
|
+
id: record.submissions.reduce((max, item) => Math.max(max, item.id), 0) + 1,
|
|
891
|
+
at,
|
|
892
|
+
groups: chosen,
|
|
893
|
+
questions: submitted,
|
|
894
|
+
claimedBy: null,
|
|
895
|
+
deliveredAt: null,
|
|
896
|
+
deliveredTo: null,
|
|
897
|
+
}
|
|
898
|
+
record.submissions.push(submission)
|
|
899
|
+
record.updatedAt = at
|
|
900
|
+
writeJson(file, record)
|
|
901
|
+
return { ok: true, submission, skipped }
|
|
902
|
+
})
|
|
903
|
+
},
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* List submissions that are still waiting for a wake.
|
|
907
|
+
*
|
|
908
|
+
* A claim whose process is gone no longer counts as live, so the
|
|
909
|
+
* submission is visible again.
|
|
910
|
+
*
|
|
911
|
+
* @param {string} projectSlug - The project slug.
|
|
912
|
+
* @returns {Promise<Array<{ planSlug: string, submissions: object[] }>>} Pending work.
|
|
913
|
+
*/
|
|
914
|
+
async pendingDeliveries(projectSlug) {
|
|
915
|
+
const pending = []
|
|
916
|
+
for (const record of readPlanRecords(projectSlug)) {
|
|
917
|
+
const submissions = record.submissions.filter(
|
|
918
|
+
(submission) =>
|
|
919
|
+
submission.deliveredAt == null &&
|
|
920
|
+
!(submission.claimedBy && isPidAlive(submission.claimedBy.pid)),
|
|
921
|
+
)
|
|
922
|
+
if (submissions.length > 0) pending.push({ planSlug: record.slug, submissions })
|
|
923
|
+
}
|
|
924
|
+
return pending
|
|
925
|
+
},
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Claim one submission for a wake. Returns false when another live claim
|
|
929
|
+
* exists or the submission was already delivered.
|
|
930
|
+
*
|
|
931
|
+
* @param {string} projectSlug - The project slug.
|
|
932
|
+
* @param {string} planSlug - The plan slug.
|
|
933
|
+
* @param {number} submissionId - The submission id.
|
|
934
|
+
* @param {string} sessionID - The claiming session.
|
|
935
|
+
* @returns {Promise<boolean>} True when this call owns the claim.
|
|
936
|
+
*/
|
|
937
|
+
async claimDelivery(projectSlug, planSlug, submissionId, sessionID) {
|
|
938
|
+
const file = planFile(projectSlug, planSlug)
|
|
939
|
+
return withFile(file, () => {
|
|
940
|
+
const record = readJson(file)
|
|
941
|
+
if (!record) return false
|
|
942
|
+
const submission = record.submissions.find((item) => item.id === submissionId)
|
|
943
|
+
if (!submission) return false
|
|
944
|
+
if (submission.deliveredAt != null) return false
|
|
945
|
+
if (submission.claimedBy && isPidAlive(submission.claimedBy.pid)) return false
|
|
946
|
+
submission.claimedBy = { pid: process.pid, at: timestamp() }
|
|
947
|
+
record.updatedAt = timestamp()
|
|
948
|
+
writeJson(file, record)
|
|
949
|
+
return true
|
|
950
|
+
})
|
|
951
|
+
},
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Release a claim after a failed wake so the next delivery point retries.
|
|
955
|
+
*
|
|
956
|
+
* @param {string} projectSlug - The project slug.
|
|
957
|
+
* @param {string} planSlug - The plan slug.
|
|
958
|
+
* @param {number} submissionId - The submission id.
|
|
959
|
+
* @returns {Promise<void>} Resolves when the file is written.
|
|
960
|
+
*/
|
|
961
|
+
async releaseDelivery(projectSlug, planSlug, submissionId) {
|
|
962
|
+
const file = planFile(projectSlug, planSlug)
|
|
963
|
+
await withFile(file, () => {
|
|
964
|
+
const record = readJson(file)
|
|
965
|
+
if (!record) return
|
|
966
|
+
const submission = record.submissions.find((item) => item.id === submissionId)
|
|
967
|
+
if (!submission) return
|
|
968
|
+
submission.claimedBy = null
|
|
969
|
+
submission.deliveredAt = null
|
|
970
|
+
submission.deliveredTo = null
|
|
971
|
+
record.updatedAt = timestamp()
|
|
972
|
+
writeJson(file, record)
|
|
973
|
+
})
|
|
974
|
+
},
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Mark claimed submissions delivered after a wake prompt resolves.
|
|
978
|
+
*
|
|
979
|
+
* Only submissions without a `deliveredAt` are changed, so a duplicate
|
|
980
|
+
* call is harmless. The claim is cleared at the same time.
|
|
981
|
+
*
|
|
982
|
+
* @param {string} projectSlug - The project slug.
|
|
983
|
+
* @param {string} planSlug - The plan slug.
|
|
984
|
+
* @param {number[]} submissionIds - The submissions the prompt covered.
|
|
985
|
+
* @param {string} sessionID - The session that was prompted.
|
|
986
|
+
* @returns {Promise<number>} How many submissions changed.
|
|
987
|
+
*/
|
|
988
|
+
async markDelivered(projectSlug, planSlug, submissionIds, sessionID) {
|
|
989
|
+
const file = planFile(projectSlug, planSlug)
|
|
990
|
+
return withFile(file, () => {
|
|
991
|
+
const record = readJson(file)
|
|
992
|
+
if (!record) return 0
|
|
993
|
+
const ids = new Set(Array.isArray(submissionIds) ? submissionIds : [])
|
|
994
|
+
const at = timestamp()
|
|
995
|
+
let count = 0
|
|
996
|
+
for (const submission of record.submissions) {
|
|
997
|
+
if (!ids.has(submission.id) || submission.deliveredAt != null) continue
|
|
998
|
+
submission.deliveredAt = at
|
|
999
|
+
submission.deliveredTo = sessionID
|
|
1000
|
+
submission.claimedBy = null
|
|
1001
|
+
count += 1
|
|
1002
|
+
}
|
|
1003
|
+
if (count > 0) {
|
|
1004
|
+
record.updatedAt = at
|
|
1005
|
+
writeJson(file, record)
|
|
1006
|
+
}
|
|
1007
|
+
return count
|
|
1008
|
+
})
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Read submitted answers, adopting the plan when it has no live owner.
|
|
1013
|
+
*
|
|
1014
|
+
* Every returned submission is marked delivered to the calling session.
|
|
1015
|
+
*
|
|
1016
|
+
* @param {string} projectSlug - The project slug.
|
|
1017
|
+
* @param {string} planSlug - The plan slug.
|
|
1018
|
+
* @param {string} sessionID - The calling session.
|
|
1019
|
+
* @returns {Promise<object>} The read result union.
|
|
1020
|
+
*/
|
|
1021
|
+
async readAnswers(projectSlug, planSlug, sessionID) {
|
|
1022
|
+
const file = planFile(projectSlug, planSlug)
|
|
1023
|
+
return withFile(file, () => {
|
|
1024
|
+
const record = readJson(file)
|
|
1025
|
+
if (!record) return { ok: false, reason: "not-found" }
|
|
1026
|
+
const ownerId = record.owner?.sessionID ?? null
|
|
1027
|
+
if (ownerId !== null && !record.orphaned && ownerId !== sessionID) {
|
|
1028
|
+
return { ok: false, reason: "owned", owner: ownerId }
|
|
1029
|
+
}
|
|
1030
|
+
let changed = false
|
|
1031
|
+
if (ownerId === null || record.orphaned) {
|
|
1032
|
+
record.owner = { sessionID }
|
|
1033
|
+
record.orphaned = false
|
|
1034
|
+
changed = true
|
|
1035
|
+
}
|
|
1036
|
+
const statusByKey = new Map(
|
|
1037
|
+
record.questions.map((question) => [question.key, question.state.status]),
|
|
1038
|
+
)
|
|
1039
|
+
const submissions = record.submissions
|
|
1040
|
+
.map((submission) => ({
|
|
1041
|
+
...submission,
|
|
1042
|
+
questions: submission.questions.filter(
|
|
1043
|
+
(entry) => statusByKey.get(entry.key) === "submitted",
|
|
1044
|
+
),
|
|
1045
|
+
}))
|
|
1046
|
+
.filter((submission) => submission.questions.length > 0)
|
|
1047
|
+
const at = timestamp()
|
|
1048
|
+
for (const submission of submissions) {
|
|
1049
|
+
if (submission.deliveredAt == null) {
|
|
1050
|
+
submission.deliveredAt = at
|
|
1051
|
+
submission.deliveredTo = sessionID
|
|
1052
|
+
submission.claimedBy = null
|
|
1053
|
+
changed = true
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (changed) {
|
|
1057
|
+
record.updatedAt = at
|
|
1058
|
+
writeJson(file, record)
|
|
1059
|
+
}
|
|
1060
|
+
return { ok: true, plan: record, submissions }
|
|
1061
|
+
})
|
|
1062
|
+
},
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Record submitted questions as resolved, or retire questions.
|
|
1066
|
+
*
|
|
1067
|
+
* The whole call is validated before anything changes; one unknown or
|
|
1068
|
+
* non-submitted key refuses the batch.
|
|
1069
|
+
*
|
|
1070
|
+
* @param {string} projectSlug - The project slug.
|
|
1071
|
+
* @param {string} planSlug - The plan slug.
|
|
1072
|
+
* @param {string} sessionID - The calling session.
|
|
1073
|
+
* @param {{ recorded?: Array<{ key: string, as: string }>, retire?: string[] }} input -
|
|
1074
|
+
* Resolution input.
|
|
1075
|
+
* @returns {Promise<object>} The resolve result union.
|
|
1076
|
+
*/
|
|
1077
|
+
async resolve(projectSlug, planSlug, sessionID, input) {
|
|
1078
|
+
const file = planFile(projectSlug, planSlug)
|
|
1079
|
+
return withFile(file, () => {
|
|
1080
|
+
const record = readJson(file)
|
|
1081
|
+
if (!record) return { ok: false, reason: "not-found" }
|
|
1082
|
+
const ownerId = record.owner?.sessionID ?? null
|
|
1083
|
+
if (ownerId !== null && !record.orphaned && ownerId !== sessionID) {
|
|
1084
|
+
return { ok: false, reason: "owned", owner: ownerId }
|
|
1085
|
+
}
|
|
1086
|
+
if (ownerId === null || record.orphaned) {
|
|
1087
|
+
record.owner = { sessionID }
|
|
1088
|
+
record.orphaned = false
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const recorded = Array.isArray(input?.recorded) ? input.recorded : []
|
|
1092
|
+
const retire = Array.isArray(input?.retire) ? input.retire : []
|
|
1093
|
+
const byKey = new Map(record.questions.map((question) => [question.key, question]))
|
|
1094
|
+
const errors = []
|
|
1095
|
+
for (const entry of recorded) {
|
|
1096
|
+
const question = byKey.get(entry?.key)
|
|
1097
|
+
if (!question) errors.push(`${entry?.key}: unknown question`)
|
|
1098
|
+
else if (question.state.status !== "submitted") {
|
|
1099
|
+
errors.push(`${entry.key}: only a submitted question can be recorded`)
|
|
1100
|
+
} else if (typeof entry.as !== "string" || entry.as.trim().length === 0) {
|
|
1101
|
+
errors.push(`${entry.key}: recordedAs is required`)
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
for (const key of retire) {
|
|
1105
|
+
if (!byKey.has(key)) errors.push(`${key}: unknown question`)
|
|
1106
|
+
}
|
|
1107
|
+
if (errors.length > 0) return { ok: false, reason: "invalid", errors }
|
|
1108
|
+
|
|
1109
|
+
let recordedCount = 0
|
|
1110
|
+
let retiredCount = 0
|
|
1111
|
+
for (const entry of recorded) {
|
|
1112
|
+
const question = byKey.get(entry.key)
|
|
1113
|
+
question.state.status = "recorded"
|
|
1114
|
+
question.state.recordedAs = entry.as.trim()
|
|
1115
|
+
recordedCount += 1
|
|
1116
|
+
}
|
|
1117
|
+
for (const key of retire) {
|
|
1118
|
+
const question = byKey.get(key)
|
|
1119
|
+
if (question.state.status === "recorded") continue
|
|
1120
|
+
question.state.status = "retired"
|
|
1121
|
+
retiredCount += 1
|
|
1122
|
+
}
|
|
1123
|
+
record.updatedAt = timestamp()
|
|
1124
|
+
writeJson(file, record)
|
|
1125
|
+
return { ok: true, recorded: recordedCount, retired: retiredCount }
|
|
1126
|
+
})
|
|
1127
|
+
},
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Mark every plan owned by a session as orphaned so the next session adopts it.
|
|
1131
|
+
*
|
|
1132
|
+
* @param {string} projectSlug - The project slug.
|
|
1133
|
+
* @param {string} sessionID - The session that disappeared.
|
|
1134
|
+
* @returns {Promise<number>} How many plans became orphaned.
|
|
1135
|
+
*/
|
|
1136
|
+
async markOrphaned(projectSlug, sessionID) {
|
|
1137
|
+
let count = 0
|
|
1138
|
+
for (const summary of readPlanRecords(projectSlug)) {
|
|
1139
|
+
const file = planFile(projectSlug, summary.slug)
|
|
1140
|
+
const changed = await withFile(file, () => {
|
|
1141
|
+
const record = readJson(file)
|
|
1142
|
+
if (!record || record.owner?.sessionID !== sessionID) return false
|
|
1143
|
+
record.orphaned = true
|
|
1144
|
+
record.updatedAt = timestamp()
|
|
1145
|
+
writeJson(file, record)
|
|
1146
|
+
return true
|
|
1147
|
+
})
|
|
1148
|
+
if (changed) count += 1
|
|
1149
|
+
}
|
|
1150
|
+
return count
|
|
1151
|
+
},
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Archive or unarchive one plan.
|
|
1155
|
+
*
|
|
1156
|
+
* @param {string} projectSlug - The project slug.
|
|
1157
|
+
* @param {string} planSlug - The plan slug.
|
|
1158
|
+
* @param {boolean} archived - The new archive flag.
|
|
1159
|
+
* @returns {Promise<boolean>} False when the plan does not exist.
|
|
1160
|
+
*/
|
|
1161
|
+
async setArchived(projectSlug, planSlug, archived) {
|
|
1162
|
+
const file = planFile(projectSlug, planSlug)
|
|
1163
|
+
return withFile(file, () => {
|
|
1164
|
+
const record = readJson(file)
|
|
1165
|
+
if (!record) return false
|
|
1166
|
+
record.archived = archived === true
|
|
1167
|
+
record.updatedAt = timestamp()
|
|
1168
|
+
writeJson(file, record)
|
|
1169
|
+
return true
|
|
1170
|
+
})
|
|
1171
|
+
},
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Delete one plan file. Nothing is deleted automatically.
|
|
1175
|
+
*
|
|
1176
|
+
* @param {string} projectSlug - The project slug.
|
|
1177
|
+
* @param {string} planSlug - The plan slug.
|
|
1178
|
+
* @returns {Promise<boolean>} False when the plan does not exist.
|
|
1179
|
+
*/
|
|
1180
|
+
async deletePlan(projectSlug, planSlug) {
|
|
1181
|
+
const file = planFile(projectSlug, planSlug)
|
|
1182
|
+
return withFile(file, () => {
|
|
1183
|
+
if (!existsSync(file)) return false
|
|
1184
|
+
unlinkSync(file)
|
|
1185
|
+
return true
|
|
1186
|
+
})
|
|
1187
|
+
},
|
|
1188
|
+
}
|
|
1189
|
+
}
|