superdev-cli 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "source": "ui/",
3
- "sourceHash": "57c0be5885fc42ef1aa0df9442a8128372ab0db907bc347bfcc7fe02b6e0f462",
4
- "sourceFileCount": 84,
5
- "bundleHash": "59cd09db3329c8937d2dce5e9c210df58feb9bcfb9d8874fb80cd54c1e0ff682",
6
- "bundleBytes": 2002963
3
+ "sourceHash": "f542076f8b1126465fb2eed5f508a4aeacb00acee8a6b18541afd82081c9f8d7",
4
+ "sourceFileCount": 85,
5
+ "bundleHash": "6af5d685e8252a7259e163159fd31bc980e74268d645c4a3317c398920407f4c",
6
+ "bundleBytes": 2007051
7
7
  }
@@ -135,6 +135,19 @@ export function probeHealth(host, port, timeout = PROBE_TIMEOUT_MS) {
135
135
  });
136
136
  }
137
137
 
138
+ /**
139
+ * A path a reader can retype, shortened at home so a long absolute path does not
140
+ * bury the part that identifies the project.
141
+ *
142
+ * Every message about a port used to describe it without saying whose it was,
143
+ * even though the health probe had already been handed the answer, so finding out
144
+ * meant opening candidate directories one at a time.
145
+ */
146
+ export function displayRoot(path) {
147
+ const home = homedir();
148
+ return home && path.startsWith(`${home}/`) ? `~/${path.slice(home.length + 1)}` : path;
149
+ }
150
+
138
151
  /**
139
152
  * What is actually true for this project right now. Distinguishes a service we
140
153
  * manage from a stale lock, from a start in progress, from someone else holding
@@ -160,14 +173,31 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
160
173
  };
161
174
  }
162
175
  if (health) {
176
+ // Not running, and the default port happens to be busy. Those are two
177
+ // separate facts and only the first one is about this project.
178
+ //
179
+ // This used to report `foreign`, which made both commands lie. `start`
180
+ // never blocked on it: the server scans forward from the requested port on
181
+ // EADDRINUSE, so a second project quietly lands on the next free one, and
182
+ // the message told the reader to do something the tool was already doing.
183
+ // `stop` did worse. On a project with nothing running it refused, and told
184
+ // the reader to go and stop a different project, which they have no reason
185
+ // to touch.
186
+ //
187
+ // Reserving `foreign` for the case that is genuinely somebody else's
188
+ // service in our place is what makes the word mean something.
163
189
  return {
164
- state: "foreign",
190
+ state: "stopped",
165
191
  managed: false,
166
192
  lock: null,
167
193
  health,
168
- port: candidate,
194
+ port: null,
169
195
  host,
170
- explanation: `Port ${candidate} is held by a Superdev service for a different project. Start this one on another port.`,
196
+ portInUse: candidate,
197
+ heldBy: health.projectRoot ?? null,
198
+ explanation: `Nothing is running for this project. Port ${candidate} is held by ${
199
+ health.projectRoot ? `the Superdev service for ${displayRoot(health.projectRoot)}` : "another Superdev service"
200
+ }, so starting this one will use the next free port instead. Pass --port <number> to choose.`,
171
201
  };
172
202
  }
173
203
  return {
@@ -217,7 +247,9 @@ export async function inspect(root, { port = null, host = DEFAULTS.host } = {})
217
247
  health,
218
248
  port: lock.port,
219
249
  host: lock.host ?? host,
220
- explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes. Something else took the port.`,
250
+ explanation: `Port ${lock.port} is answering, but it is not the service this lock file describes${
251
+ health.projectRoot ? `: it belongs to ${displayRoot(health.projectRoot)}` : ""
252
+ }. This project's service is gone and something else took its port. Run \`superdev start --apply\` to bring this one back on a free port.`,
221
253
  };
222
254
  }
223
255
  return {
@@ -335,7 +367,12 @@ export async function startService(root, options = {}) {
335
367
  );
336
368
  if (!held) {
337
369
  if (view.state === "foreign") {
338
- throw new ServiceError(E.FOREIGN, view.explanation, { port: view.port });
370
+ throw new ServiceError(E.FOREIGN, view.explanation, {
371
+ port: view.port,
372
+ // The caller cannot act on a refusal whose subject it cannot see, and the
373
+ // probe already knows it. `superdev services` prints it; a script reads it.
374
+ heldBy: view.health?.projectRoot ?? null,
375
+ });
339
376
  }
340
377
  return { ...summarize(root, view), alreadyRunning: true };
341
378
  }
@@ -317,12 +317,40 @@ function dependencyIds(payload) {
317
317
 
318
318
  // ----------------------------------------------------------------- questions
319
319
 
320
+ /** The question's own options. The row is read unhydrated, so the JSON is text. */
321
+ function offeredOptions(question) {
322
+ try {
323
+ const parsed = JSON.parse(question.alternatives_json ?? "[]");
324
+ return Array.isArray(parsed) ? parsed : [];
325
+ } catch {
326
+ return [];
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Answer a question, from its options or in the reader's own words.
332
+ *
333
+ * `selected` carries the options chosen; `answer` carries typed text. Either is
334
+ * enough, both together is fine (an option plus a qualification), neither is
335
+ * refused. A question that takes one answer refuses several, because a record
336
+ * saying "a single service, several services" says nothing.
337
+ *
338
+ * The work is delegated to the engine rather than repeated here. This handler
339
+ * used to write the answer and set the status itself, which looked equivalent and
340
+ * was not: the engine also settles the capability area the question belongs to,
341
+ * records a reversible assumption when the answer is "I do not know", and writes a
342
+ * project-level answer through to the field it exists to fill. So a question
343
+ * answered in the control centre left its readiness area awaiting a decision,
344
+ * while the same question answered from the command line settled it. One answer
345
+ * path is the fix; two that agree by coincidence is the defect.
346
+ */
320
347
  async function answerQuestion(root, payload) {
321
348
  const questionId = identifier(payload, "questionId", { required: true });
322
349
  const status = oneOf(payload, "status", ["answered", "deferred", "withdrawn"], {
323
350
  fallback: "answered",
324
351
  });
325
- const answer = text(payload, "answer", { required: status === "answered" });
352
+ const selected = list(payload, "selected") ?? [];
353
+ const typed = text(payload, "answer", { required: status === "answered" && !selected.length });
326
354
  const deferralReason = text(payload, "deferralReason", {
327
355
  required: status === "deferred",
328
356
  max: LIMIT.prose,
@@ -330,40 +358,60 @@ async function answerQuestion(root, payload) {
330
358
  const answeredBy = text(payload, "answeredBy", { max: LIMIT.actor });
331
359
  const actor = actorOf(payload);
332
360
 
333
- return mutate(root, async (db) => {
334
- const question = await db.get("SELECT * FROM questions WHERE id = ?", questionId);
335
- if (!question) throw new MutationError(E.NOT_FOUND, `There is no question ${questionId}.`);
336
- if (question.status !== "open" && question.status !== "deferred") {
361
+ const question = await query(root, (db) => db.get("SELECT * FROM questions WHERE id = ?", questionId));
362
+ if (!question) throw new MutationError(E.NOT_FOUND, `There is no question ${questionId}.`);
363
+ if (question.status !== "open" && question.status !== "deferred") {
364
+ throw new MutationError(
365
+ E.INVALID_TRANSITION,
366
+ `${questionId} is already ${question.status} and is not waiting on an answer.`,
367
+ );
368
+ }
369
+
370
+ if (selected.length) {
371
+ const offered = offeredOptions(question);
372
+ const unknown = selected.filter((choice) => !offered.includes(choice));
373
+ if (unknown.length) {
337
374
  throw new MutationError(
338
- E.INVALID_TRANSITION,
339
- `${questionId} is already ${question.status} and is not waiting on an answer.`,
375
+ E.INVALID_PAYLOAD,
376
+ `${questionId} does not offer ${unknown.map((u) => JSON.stringify(u)).join(", ")}. Choose from its own options, or type an answer instead.`,
340
377
  );
341
378
  }
342
- const project = await currentProject(db);
343
- await patch(
344
- db,
345
- "question",
346
- questionId,
347
- question.version,
348
- pick(
349
- {
350
- answer,
351
- answered_by: answeredBy ?? actor,
352
- answered_at: new Date().toISOString(),
353
- deferral_reason: deferralReason,
354
- },
355
- ["answer", "answered_by", "answered_at", "deferral_reason"],
356
- ),
357
- { projectId: project.id, actor, activity: false },
358
- );
359
- return setStatus(db, "question", questionId, status, {
360
- projectId: project.id,
361
- actor,
362
- note: answer ?? deferralReason,
363
- activityType: "specification_changed",
364
- activitySummary: `${questionId} ${status}: ${(answer ?? deferralReason ?? "").slice(0, 160)}`,
379
+ if (question.select_mode !== "many" && selected.length > 1) {
380
+ throw new MutationError(
381
+ E.INVALID_PAYLOAD,
382
+ `${questionId} takes one answer, and ${selected.length} were chosen. Pick one, or type an answer that says what you mean.`,
383
+ );
384
+ }
385
+ }
386
+
387
+ if (status === "withdrawn") {
388
+ return mutate(root, async (db) => {
389
+ const project = await currentProject(db);
390
+ return setStatus(db, "question", questionId, "withdrawn", {
391
+ projectId: project.id,
392
+ actor,
393
+ note: typed ?? deferralReason,
394
+ activityType: "specification_changed",
395
+ activitySummary: `${questionId} withdrawn: ${(typed ?? deferralReason ?? "no reason given").slice(0, 160)}`,
396
+ });
365
397
  });
398
+ }
399
+
400
+ // The chosen options and the typed words become one sentence, in that order,
401
+ // because the options are the shared vocabulary and the typing qualifies them.
402
+ const answer = [selected.join("; "), typed].filter(Boolean).join(". ") || null;
403
+ const { answerQuestion: record } = await import("../init/questions.mjs");
404
+ const outcome = await record(root, questionId, {
405
+ answer: status === "deferred" ? null : answer,
406
+ // Kept apart from the composed answer so a question that writes through to a
407
+ // project field gets the sentence somebody wrote, not the option they picked.
408
+ inOwnWords: typed,
409
+ unknown: status === "deferred",
410
+ revisitTrigger: status === "deferred" ? deferralReason : null,
411
+ actor,
412
+ answeredBy: answeredBy ?? actor,
366
413
  });
414
+ return { id: questionId, status, answer, selected, outcome };
367
415
  }
368
416
 
369
417
  // ----------------------------------------------------------------- decisions
@@ -129,6 +129,13 @@ async function release(db, task, actor, note) {
129
129
  summary: note ?? `Assignment on ${task.id} released.`,
130
130
  metadata: { assignment: assignment.id },
131
131
  });
132
+ // Every path that ends a claim runs through here: releasing it, completing it,
133
+ // cancelling it. So this is the one place that has to stop pointing a session at
134
+ // a task it no longer holds, and only the session that held it is cleared.
135
+ await db.run(
136
+ "UPDATE work_sessions SET active_task_id = NULL WHERE project_id = ? AND active_task_id = ?",
137
+ task.project_id, task.id,
138
+ );
132
139
  return assignment;
133
140
  }
134
141
 
@@ -382,10 +389,48 @@ export async function claimTask(root, taskId, who = {}) {
382
389
  throw new TaskError(E.ALREADY_CLAIMED,
383
390
  `${taskId} was claimed by ${await holderOf(db, now)} a moment before this claim. Pick up another task.`);
384
391
  }
392
+ // The session is pointed at the task it just claimed.
393
+ //
394
+ // Claiming recorded an assignment and nothing else, so work_sessions.active_task_id
395
+ // stayed null on every session on every project. The session-start hook reads
396
+ // exactly that field to decide whether work is tracked, so it recorded an
397
+ // "untracked work" marker on every file edit, including edits made under a
398
+ // task that was claimed and in progress. The readiness report then raised its
399
+ // only high-severity warning, "changes were made while no task was claimed",
400
+ // against work that was properly tracked. A warning that fires when the rule
401
+ // was followed teaches the reader to stop reading warnings, and this is the
402
+ // one that means the record has fallen behind.
403
+ //
404
+ // Written here rather than in each caller, because the control centre claims
405
+ // tasks too and the field has to mean the same thing whichever surface moved it.
406
+ await pointSessionAt(db, task.project_id, sessionId, taskId);
407
+
385
408
  return db.get("SELECT * FROM tasks WHERE id = ?", taskId);
386
409
  });
387
410
  }
388
411
 
412
+ /**
413
+ * Point a session at the task it is working on, or at nothing.
414
+ *
415
+ * The session is the one named, or the most recently active one on this machine,
416
+ * because a claim from the command line does not carry a session identifier and
417
+ * the hook that reads this field cannot ask.
418
+ */
419
+ async function pointSessionAt(db, projectId, sessionId, taskId) {
420
+ const session = sessionId
421
+ ? await db.get("SELECT id FROM work_sessions WHERE id = ?", sessionId)
422
+ : await db.get(
423
+ `SELECT id FROM work_sessions
424
+ WHERE project_id = ? AND ended_at IS NULL
425
+ ORDER BY last_activity_at DESC, started_at DESC LIMIT 1`, projectId);
426
+ if (!session) return null;
427
+ await db.run(
428
+ "UPDATE work_sessions SET active_task_id = ?, last_activity_at = ? WHERE id = ?",
429
+ taskId, nowIso(), session.id,
430
+ );
431
+ return session.id;
432
+ }
433
+
389
434
  /** Hand a task back. The task keeps its status; only the claim ends. */
390
435
  export async function releaseTask(root, taskId, { actor = "superdev", reason = null } = {}) {
391
436
  return mutate(root, async (db) => {