@shivaedev/work-board 0.7.2 → 0.8.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.
Files changed (198) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +93 -2
  3. package/assets/agent.js +37 -0
  4. package/assets/native.js +32 -25
  5. package/dist/board.d.ts +1 -0
  6. package/dist/board.d.ts.map +1 -1
  7. package/dist/board.js +15 -2
  8. package/dist/board.js.map +1 -1
  9. package/dist/browser/client.d.ts +19 -79
  10. package/dist/browser/client.d.ts.map +1 -1
  11. package/dist/browser/client.js +12 -1
  12. package/dist/browser/client.js.map +1 -1
  13. package/dist/browser/entry.d.ts +3 -0
  14. package/dist/browser/entry.d.ts.map +1 -0
  15. package/dist/browser/entry.js +7 -0
  16. package/dist/browser/entry.js.map +1 -0
  17. package/dist/browser/responses/draftSession.d.ts +8 -0
  18. package/dist/browser/responses/draftSession.d.ts.map +1 -0
  19. package/dist/browser/responses/draftSession.js +92 -0
  20. package/dist/browser/responses/draftSession.js.map +1 -0
  21. package/dist/browser/responses/drafts.d.ts +16 -0
  22. package/dist/browser/responses/drafts.d.ts.map +1 -0
  23. package/dist/browser/responses/drafts.js +49 -0
  24. package/dist/browser/responses/drafts.js.map +1 -0
  25. package/dist/browser/responses/preview.d.ts +3 -0
  26. package/dist/browser/responses/preview.d.ts.map +1 -0
  27. package/dist/browser/responses/preview.js +4 -0
  28. package/dist/browser/responses/preview.js.map +1 -0
  29. package/dist/browser/responses/schema.d.ts +127 -0
  30. package/dist/browser/responses/schema.d.ts.map +1 -0
  31. package/dist/browser/responses/schema.js +47 -0
  32. package/dist/browser/responses/schema.js.map +1 -0
  33. package/dist/browser/responses/setup.d.ts +2 -0
  34. package/dist/browser/responses/setup.d.ts.map +1 -0
  35. package/dist/browser/responses/setup.js +99 -0
  36. package/dist/browser/responses/setup.js.map +1 -0
  37. package/dist/browser/responses/submit.d.ts +4 -0
  38. package/dist/browser/responses/submit.d.ts.map +1 -0
  39. package/dist/browser/responses/submit.js +36 -0
  40. package/dist/browser/responses/submit.js.map +1 -0
  41. package/dist/browser/session.d.ts +1 -79
  42. package/dist/browser/session.d.ts.map +1 -1
  43. package/dist/cli.d.ts.map +1 -1
  44. package/dist/cli.js +24 -15
  45. package/dist/cli.js.map +1 -1
  46. package/dist/files/changes.d.ts +1 -0
  47. package/dist/files/changes.d.ts.map +1 -1
  48. package/dist/files/changes.js +1 -1
  49. package/dist/files/changes.js.map +1 -1
  50. package/dist/http/respond.d.ts.map +1 -1
  51. package/dist/http/respond.js +2 -0
  52. package/dist/http/respond.js.map +1 -1
  53. package/dist/metadata/backlinks.d.ts.map +1 -1
  54. package/dist/metadata/backlinks.js +2 -0
  55. package/dist/metadata/backlinks.js.map +1 -1
  56. package/dist/metadata/fields.d.ts +17 -1
  57. package/dist/metadata/fields.d.ts.map +1 -1
  58. package/dist/metadata/problems.d.ts.map +1 -1
  59. package/dist/metadata/problems.js +2 -0
  60. package/dist/metadata/problems.js.map +1 -1
  61. package/dist/metadata/schema.d.ts +17 -1
  62. package/dist/metadata/schema.d.ts.map +1 -1
  63. package/dist/metadata/schema.js +6 -2
  64. package/dist/metadata/schema.js.map +1 -1
  65. package/dist/page/attentionDetails.d.ts.map +1 -1
  66. package/dist/page/attentionDetails.js +2 -1
  67. package/dist/page/attentionDetails.js.map +1 -1
  68. package/dist/page/metadataDetails.d.ts.map +1 -1
  69. package/dist/page/metadataDetails.js +6 -0
  70. package/dist/page/metadataDetails.js.map +1 -1
  71. package/dist/page/navigationScript.js +1 -1
  72. package/dist/page/shell.d.ts +1 -1
  73. package/dist/page/shell.d.ts.map +1 -1
  74. package/dist/page/shell.js.map +1 -1
  75. package/dist/page/style.d.ts.map +1 -1
  76. package/dist/page/style.js +2 -1
  77. package/dist/page/style.js.map +1 -1
  78. package/dist/page/swap.d.ts +1 -1
  79. package/dist/page/swap.d.ts.map +1 -1
  80. package/dist/page/swap.js +3 -0
  81. package/dist/page/swap.js.map +1 -1
  82. package/dist/path/Identity.d.ts +3 -0
  83. package/dist/path/Identity.d.ts.map +1 -0
  84. package/dist/path/Identity.js +3 -0
  85. package/dist/path/Identity.js.map +1 -0
  86. package/dist/responses/agent-cli.d.ts +2 -0
  87. package/dist/responses/agent-cli.d.ts.map +1 -0
  88. package/dist/responses/agent-cli.js +31 -0
  89. package/dist/responses/agent-cli.js.map +1 -0
  90. package/dist/responses/agent.d.ts +68 -0
  91. package/dist/responses/agent.d.ts.map +1 -0
  92. package/dist/responses/agent.js +49 -0
  93. package/dist/responses/agent.js.map +1 -0
  94. package/dist/responses/await.d.ts +33 -0
  95. package/dist/responses/await.d.ts.map +1 -0
  96. package/dist/responses/await.js +21 -0
  97. package/dist/responses/await.js.map +1 -0
  98. package/dist/responses/flags.d.ts +6 -0
  99. package/dist/responses/flags.d.ts.map +1 -0
  100. package/dist/responses/flags.js +23 -0
  101. package/dist/responses/flags.js.map +1 -0
  102. package/dist/responses/locate.d.ts +13 -0
  103. package/dist/responses/locate.d.ts.map +1 -0
  104. package/dist/responses/locate.js +50 -0
  105. package/dist/responses/locate.js.map +1 -0
  106. package/dist/responses/outcome.d.ts +65 -0
  107. package/dist/responses/outcome.d.ts.map +1 -0
  108. package/dist/responses/outcome.js +14 -0
  109. package/dist/responses/outcome.js.map +1 -0
  110. package/dist/responses/page.d.ts +6 -0
  111. package/dist/responses/page.d.ts.map +1 -0
  112. package/dist/responses/page.js +25 -0
  113. package/dist/responses/page.js.map +1 -0
  114. package/dist/responses/publish.d.ts +4 -0
  115. package/dist/responses/publish.d.ts.map +1 -0
  116. package/dist/responses/publish.js +56 -0
  117. package/dist/responses/publish.js.map +1 -0
  118. package/dist/responses/publishIn.d.ts +3 -0
  119. package/dist/responses/publishIn.d.ts.map +1 -0
  120. package/dist/responses/publishIn.js +69 -0
  121. package/dist/responses/publishIn.js.map +1 -0
  122. package/dist/responses/read.d.ts +30 -0
  123. package/dist/responses/read.d.ts.map +1 -0
  124. package/dist/responses/read.js +43 -0
  125. package/dist/responses/read.js.map +1 -0
  126. package/dist/responses/records.d.ts +12 -0
  127. package/dist/responses/records.d.ts.map +1 -0
  128. package/dist/responses/records.js +47 -0
  129. package/dist/responses/records.js.map +1 -0
  130. package/dist/responses/retry.d.ts +22 -0
  131. package/dist/responses/retry.d.ts.map +1 -0
  132. package/dist/responses/retry.js +14 -0
  133. package/dist/responses/retry.js.map +1 -0
  134. package/dist/responses/service.d.ts +106 -0
  135. package/dist/responses/service.d.ts.map +1 -0
  136. package/dist/responses/service.js +90 -0
  137. package/dist/responses/service.js.map +1 -0
  138. package/dist/responses/waitFor.d.ts +31 -0
  139. package/dist/responses/waitFor.d.ts.map +1 -0
  140. package/dist/responses/waitFor.js +47 -0
  141. package/dist/responses/waitFor.js.map +1 -0
  142. package/dist/rpc/contract.d.ts +101 -2
  143. package/dist/rpc/contract.d.ts.map +1 -1
  144. package/dist/rpc/contract.js +3 -2
  145. package/dist/rpc/contract.js.map +1 -1
  146. package/dist/rpc/responseContract.d.ts +212 -0
  147. package/dist/rpc/responseContract.d.ts.map +1 -0
  148. package/dist/rpc/responseContract.js +37 -0
  149. package/dist/rpc/responseContract.js.map +1 -0
  150. package/dist/rpc/server.d.ts +3 -1
  151. package/dist/rpc/server.d.ts.map +1 -1
  152. package/dist/rpc/server.js +6 -1
  153. package/dist/rpc/server.js.map +1 -1
  154. package/dist/serve-cli.d.ts +3 -0
  155. package/dist/serve-cli.d.ts.map +1 -0
  156. package/dist/serve-cli.js +20 -0
  157. package/dist/serve-cli.js.map +1 -0
  158. package/package.json +2 -2
  159. package/src/board.ts +16 -2
  160. package/src/browser/client.ts +30 -4
  161. package/src/browser/entry.ts +9 -0
  162. package/src/browser/responses/draftSession.ts +88 -0
  163. package/src/browser/responses/drafts.ts +58 -0
  164. package/src/browser/responses/preview.ts +5 -0
  165. package/src/browser/responses/schema.ts +52 -0
  166. package/src/browser/responses/setup.ts +102 -0
  167. package/src/browser/responses/submit.ts +41 -0
  168. package/src/cli.ts +14 -22
  169. package/src/files/changes.ts +2 -1
  170. package/src/http/respond.ts +2 -0
  171. package/src/metadata/backlinks.ts +2 -0
  172. package/src/metadata/problems.ts +2 -0
  173. package/src/metadata/schema.ts +6 -2
  174. package/src/page/attentionDetails.ts +2 -1
  175. package/src/page/metadataDetails.ts +6 -0
  176. package/src/page/navigationScript.ts +1 -1
  177. package/src/page/shell.ts +1 -1
  178. package/src/page/style.ts +2 -1
  179. package/src/page/swap.ts +3 -0
  180. package/src/path/Identity.ts +3 -0
  181. package/src/responses/agent-cli.ts +35 -0
  182. package/src/responses/agent.ts +59 -0
  183. package/src/responses/await.ts +21 -0
  184. package/src/responses/flags.ts +22 -0
  185. package/src/responses/locate.ts +55 -0
  186. package/src/responses/outcome.ts +15 -0
  187. package/src/responses/page.ts +41 -0
  188. package/src/responses/publish.ts +53 -0
  189. package/src/responses/publishIn.ts +64 -0
  190. package/src/responses/read.ts +45 -0
  191. package/src/responses/records.ts +53 -0
  192. package/src/responses/retry.ts +23 -0
  193. package/src/responses/service.ts +106 -0
  194. package/src/responses/waitFor.ts +58 -0
  195. package/src/rpc/contract.ts +3 -2
  196. package/src/rpc/responseContract.ts +46 -0
  197. package/src/rpc/server.ts +8 -1
  198. package/src/serve-cli.ts +37 -0
@@ -27,6 +27,8 @@ function criterionProblems(document: MetadataDocument): readonly Diagnostic[] {
27
27
  function referenceProblems(document: MetadataDocument, ids: ReadonlyMap<string, readonly MetadataDocument[]>): readonly Diagnostic[] {
28
28
  const { fields, lines } = document.parsed;
29
29
  const references = [
30
+ ...(fields.question ? [{ field: "question", key: "question.item", target: fields.question.item }] : []),
31
+ ...(fields.response ? [{ field: "response", key: "response.question", target: fields.response.question }] : []),
30
32
  ...(fields.attention?.flatMap((request, index) =>
31
33
  request.unblocks.map((target, targetIndex) => ({ field: "attention", key: `attention.${index}.unblocks.${targetIndex}`, target })),
32
34
  ) ?? []),
@@ -1,6 +1,8 @@
1
1
  import { Schema } from "effect";
2
+ import { QuestionRecord, ResponseRecord } from "#browser/responses/schema.ts";
3
+ import { Identity as SourceIdentity } from "#path/Identity.ts";
2
4
 
3
- export const Identity = Schema.String.check(Schema.isPattern(/^[a-zA-Z\d][a-zA-Z\d._-]*$/u));
5
+ export const Identity = SourceIdentity;
4
6
  export const Reference = Schema.String.check(Schema.isPattern(/^[a-zA-Z\d][a-zA-Z\d._-]*(?:#[a-zA-Z\d][a-zA-Z\d._-]*)?$/u));
5
7
  const Text = Schema.String.check(Schema.isPattern(/\S/u));
6
8
  const Attention = Schema.Struct({
@@ -36,10 +38,12 @@ export const Metadata = Schema.Struct({
36
38
  evidence: Schema.optional(Schema.Array(Evidence)),
37
39
  id: Schema.optional(Identity),
38
40
  items: Schema.optional(Schema.Array(Identity)),
39
- kind: Schema.optional(Schema.Literals(["task", "investigation", "decision", "result", "project", "board"])),
41
+ kind: Schema.optional(Schema.Literals(["task", "investigation", "decision", "result", "project", "board", "question", "response"])),
40
42
  nextAction: Schema.optional(Text),
41
43
  owner: Schema.optional(Text),
44
+ question: Schema.optional(QuestionRecord),
42
45
  relationships: Schema.optional(Schema.Array(Relationship)),
46
+ response: Schema.optional(ResponseRecord),
43
47
  status: Schema.optional(Text),
44
48
  }).pipe(Schema.encodeKeys({ nextAction: "next_action" }));
45
49
  export type Metadata = typeof Metadata.Type;
@@ -1,4 +1,5 @@
1
1
  import { attentionSource } from "#attention/requests.ts";
2
+ import { identityUrl } from "#metadata/links.ts";
2
3
  import type { MetadataModel } from "#metadata/model.ts";
3
4
  import type { ParsedMetadata } from "#metadata/parse.ts";
4
5
  import { escapeHtml } from "./escape.ts";
@@ -12,7 +13,7 @@ export function attentionDetails(parsed: ParsedMetadata, file: string, model: Me
12
13
  return `<h3>Recorded attention requests</h3><p>Open/closed is source state. Closing a request does not establish acceptance or record an answer.</p><ul>${requests
13
14
  .map(
14
15
  (request, index) =>
15
- `<li id="${attentionSource({ file, parsed }, index)}" tabindex="-1"><b>${escapeHtml(request.kind)} · ${escapeHtml(request.id)} · ${escapeHtml(request.state)}</b><p>${escapeHtml(request.reason)}</p><p>Response needed from: ${request.responseFrom.map(escapeHtml).join(", ")}</p><p>Recorded to unblock: ${request.unblocks.map((target) => referenceHtml(target, target, model)).join(", ")}</p><p>Source: ${escapeHtml(file)}:${parsed.lines[`attention.${index}`] ?? parsed.lines.attention ?? 2}</p></li>`,
16
+ `<li id="${attentionSource({ file, parsed }, index)}" tabindex="-1"><b>${escapeHtml(request.kind)} · ${escapeHtml(request.id)} · ${escapeHtml(request.state)}</b><p>${escapeHtml(request.reason)}</p><p>Response needed from: ${request.responseFrom.map(escapeHtml).join(", ")}</p><p>Recorded to unblock: ${request.unblocks.map((target) => referenceHtml(target, target, model)).join(", ")}</p><p><a href="/_board/respond?item=${encodeURIComponent(parsed.fields.id ?? "")}&amp;request=${encodeURIComponent(request.id)}">Respond to this request</a> · <a href="${identityUrl(parsed.fields.id ?? "")}">Reviewed item</a></p><p>Source: ${escapeHtml(file)}:${parsed.lines[`attention.${index}`] ?? parsed.lines.attention ?? 2}</p></li>`,
16
17
  )
17
18
  .join("")}</ul>`;
18
19
  }
@@ -18,6 +18,12 @@ export function metadataDetails(parsed: ParsedMetadata, file: string, model: Met
18
18
  ? `<h3>Declared board membership</h3><ul>${fields.items.map((id) => `<li>${referenceHtml(id, id, model)}</li>`).join("")}</ul>`
19
19
  : "",
20
20
 
21
+ fields.question
22
+ ? `<h3>Reviewed question</h3><p>${referenceHtml(fields.question.item, fields.question.item, model)} / ${escapeHtml(fields.question.request)} · ${escapeHtml(fields.question.reason)}</p><p>Reviewed SHA-256: <code>${fields.question.reviewedRevision}</code>. Registered: ${new Date(fields.question.registeredAt).toISOString()}. Unanswered deadline: ${new Date(fields.question.deadline).toISOString()}.</p>`
23
+ : "",
24
+ fields.response
25
+ ? `<h3>Recorded response</h3><p>${referenceHtml(fields.response.question, fields.response.question, model)} · ${escapeHtml(fields.response.author)} · ${escapeHtml(fields.response.type)}</p><p>Reviewed SHA-256: <code>${fields.response.reviewedRevision}</code>. This is authored feedback, not verified acceptance.</p>`
26
+ : "",
21
27
  criteriaEvidence(parsed, model),
22
28
  attentionDetails(parsed, file, model),
23
29
  recordedEvidence(parsed, file, model),
@@ -88,7 +88,7 @@ document.addEventListener("click", (event) => {
88
88
  const link = event.target.closest?.("a[href]");
89
89
  if (!link || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || link.hasAttribute("download") || (link.target && link.target !== "_self")) return;
90
90
  const target = new URL(link.href, location.href);
91
- if (target.origin !== location.origin || !(target.pathname === "/" || (target.pathname === "/_board/start" || target.pathname === "/_board/changes" || target.pathname === "/_board/overview" || target.pathname === "/_board/work" || target.pathname.startsWith("/_board/item/")) || /\\.md$/i.test(target.pathname))) return;
91
+ if (target.origin !== location.origin || !(target.pathname === "/" || (target.pathname === "/_board/respond" || target.pathname === "/_board/start" || target.pathname === "/_board/changes" || target.pathname === "/_board/overview" || target.pathname === "/_board/work" || target.pathname.startsWith("/_board/item/")) || /\\.md$/i.test(target.pathname))) return;
92
92
  event.preventDefault();
93
93
  navigate(target);
94
94
  });
package/src/page/shell.ts CHANGED
@@ -17,7 +17,7 @@ export const shell = (
17
17
  workspace: string,
18
18
  board: boolean,
19
19
  identity?: string,
20
- view: boolean | "overview" | "changes" | "start" = false,
20
+ view: boolean | "overview" | "changes" | "start" | "response" = false,
21
21
  ): string =>
22
22
  [
23
23
  `<!doctype html><html lang="en" data-workspace="${escapeHtml(workspace)}"><head><meta charset="utf-8">`,
package/src/page/style.ts CHANGED
@@ -96,4 +96,5 @@ export const style =
96
96
  + workStyle()
97
97
  + attentionStyle()
98
98
  + visualStyles
99
- + documents;
99
+ + documents
100
+ + ".response-panel { max-width: 850px; margin: auto; padding: 1.5rem; } .response-panel form { display: grid; gap: 1rem; } .response-panel label { display: grid; gap: .5rem; } .response-panel input, .response-panel textarea, .response-panel select { width: 100%; box-sizing: border-box; font: inherit; background: var(--surface); color: inherit; padding: .75rem; border: 1px solid var(--border); } .response-panel pre, .response-panel code { white-space: pre-wrap; overflow-wrap: anywhere; }";
package/src/page/swap.ts CHANGED
@@ -18,6 +18,9 @@ export const remember = (element) => {
18
18
 
19
19
  const normalized = (element) => {
20
20
  const copy = element.cloneNode(true);
21
+ for (const form of [copy, ...copy.querySelectorAll("#response-form")]) {
22
+ if (form.id === "response-form") { form.replaceChildren(); delete form.dataset.bound; }
23
+ }
21
24
  for (const tool of copy.querySelectorAll("[data-visual-tools], [data-image-error], [data-diagram-error]")) tool.remove();
22
25
  for (const node of [copy, ...copy.querySelectorAll("*")]) {
23
26
  node.removeAttribute("data-live-change");
@@ -0,0 +1,3 @@
1
+ import { Schema } from "effect";
2
+
3
+ export const Identity = Schema.String.check(Schema.isPattern(/^[a-zA-Z\d][a-zA-Z\d._-]*$/u));
@@ -0,0 +1,35 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { agentRequest } from "./agent.ts";
3
+ import { flags } from "./flags.ts";
4
+
5
+ export async function runAgent(args: readonly string[]) {
6
+ const [action, request, ...options] = args;
7
+ if ((action !== "wait" && action !== "response" && action !== "question") || !request) {
8
+ throw new Error(
9
+ "Usage: work-board question <item>/<request> | wait <question-id> [--after <response-id>] | response <question-id> [--port 4747]",
10
+ );
11
+ }
12
+ const parsed = flags(options);
13
+ const controller = new AbortController();
14
+ function cancel() {
15
+ controller.abort();
16
+ }
17
+ process.once("SIGINT", cancel);
18
+ process.once("SIGTERM", cancel);
19
+ try {
20
+ const result = await Effect.runPromise(
21
+ agentRequest(action, { ...parsed, request }, (text) => process.stderr.write(`${text}\n`)).pipe(Effect.result),
22
+ { signal: controller.signal },
23
+ );
24
+ if (result._tag === "Failure") {
25
+ if (result.failure instanceof Error && "code" in result.failure && result.failure.code === "Unanswered") {
26
+ process.exitCode = 2;
27
+ }
28
+ throw result.failure;
29
+ }
30
+ process.stdout.write(`${JSON.stringify(result.success)}\n`);
31
+ } finally {
32
+ process.removeListener("SIGINT", cancel);
33
+ process.removeListener("SIGTERM", cancel);
34
+ }
35
+ }
@@ -0,0 +1,59 @@
1
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import * as RpcClient from "effect/unstable/rpc/RpcClient";
5
+ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
6
+ import { responseContract } from "#rpc/responseContract.ts";
7
+ import { waitForResponse } from "./waitFor.ts";
8
+
9
+ export interface AgentOptions {
10
+ readonly after?: string | undefined;
11
+ readonly request: string;
12
+ readonly revision?: string | undefined;
13
+ readonly url: string;
14
+ }
15
+ function protocol(url: string) {
16
+ return RpcClient.layerProtocolHttp({ url: `${url}/_board/rpc` }).pipe(Layer.provide([NodeHttpClient.layerNodeHttp, RpcSerialization.layerNdjson]));
17
+ }
18
+ function validate(url: string) {
19
+ const parsed = new URL(url);
20
+ if (
21
+ parsed.protocol !== "http:"
22
+ || !["127.0.0.1", "localhost", "[::1]"].includes(parsed.hostname)
23
+ || parsed.username
24
+ || parsed.password
25
+ || parsed.pathname !== "/"
26
+ || parsed.search
27
+ || parsed.hash
28
+ ) {
29
+ throw new Error("Use a loopback Work Board URL, such as http://127.0.0.1:4747.");
30
+ }
31
+ return parsed.origin;
32
+ }
33
+ export function agentRequest(action: "question" | "response" | "wait", options: AgentOptions, diagnostic: (text: string) => void = () => undefined) {
34
+ return Effect.scoped(
35
+ Effect.gen(function* () {
36
+ const client = yield* RpcClient.make(responseContract, { flatten: true });
37
+ const [item, request, extra] = options.request.split("/");
38
+ if (action === "question") {
39
+ if (!(item && request) || extra) {
40
+ return yield* Effect.fail(new Error("Use question <item>/<request>."));
41
+ }
42
+ return yield* client("work-board.question", { item, request });
43
+ }
44
+ let id = options.request;
45
+ if (request !== undefined) {
46
+ if (action !== "wait" || !item || !request || extra || !options.revision) {
47
+ return yield* Effect.fail(new Error("Use wait <item>/<request> --revision <reviewed SHA-256>, or wait <registered-question-id>."));
48
+ }
49
+ const question = yield* client("work-board.registerQuestion", { item, request, revision: options.revision });
50
+ id = question.id;
51
+ diagnostic(`Registered ${id}; reattach with work-board wait ${id}. Deadline: ${new Date(question.question.deadline).toISOString()}`);
52
+ }
53
+ if (action === "response") {
54
+ return yield* client("work-board.responses", { question: id });
55
+ }
56
+ return yield* waitForResponse(() => client("work-board.awaitResponse", { after: options.after, question: id }), diagnostic);
57
+ }),
58
+ ).pipe(Effect.provide(protocol(validate(options.url))));
59
+ }
@@ -0,0 +1,21 @@
1
+ import { Clock, Effect } from "effect";
2
+ import { type Reading, ResponseFailed } from "#browser/responses/schema.ts";
3
+ import { outcome } from "./outcome.ts";
4
+
5
+ export function awaitResponse(read: (id: string) => Effect.Effect<Reading, ResponseFailed>) {
6
+ return Effect.fn("WorkBoard.awaitResponse")(function* (input: { readonly question: string; readonly after?: string | undefined }) {
7
+ const lease = (yield* Clock.currentTimeMillis) + 30_000;
8
+ while (true) {
9
+ const result = yield* read(input.question);
10
+ const now = yield* Clock.currentTimeMillis;
11
+ const state = outcome(result, input.after, now);
12
+ if (state.kind === "missing_cursor") {
13
+ return yield* Effect.fail(new ResponseFailed({ code: "Missing", message: "The next-response cursor is not recorded for this question." }));
14
+ }
15
+ if (state.kind !== "pending" || now >= lease) {
16
+ return { expired: state.kind === "unanswered", question: result.question, response: state.kind === "response" ? state.response : undefined };
17
+ }
18
+ yield* Effect.sleep("1 second");
19
+ }
20
+ });
21
+ }
@@ -0,0 +1,22 @@
1
+ export function flags(args: readonly string[]) {
2
+ const values = new Map<string, string>();
3
+ for (let index = 0; index < args.length; index += 2) {
4
+ const name = args[index];
5
+ const value = args[index + 1];
6
+ if (!(name && value && ["--port", "--url", "--revision", "--after"].includes(name))) {
7
+ throw new Error("Use --port, --url, --revision or --after, each with a value.");
8
+ }
9
+ if (values.has(name)) {
10
+ throw new Error(`Repeated option ${name}`);
11
+ }
12
+ values.set(name, value);
13
+ }
14
+ const port = values.get("--port") ?? "4747";
15
+ if (/^\d{1,5}$/u.exec(port) === null || Number(port) > 65_535 || Number(port) < 1) {
16
+ throw new Error("Invalid port.");
17
+ }
18
+ if (values.has("--url") && values.has("--port")) {
19
+ throw new Error("Choose --url or --port.");
20
+ }
21
+ return { after: values.get("--after"), revision: values.get("--revision"), url: values.get("--url") ?? `http://127.0.0.1:${port}` };
22
+ }
@@ -0,0 +1,55 @@
1
+ import { Effect, type FileSystem } from "effect";
2
+ import { attentionRequests } from "#attention/requests.ts";
3
+ import { type QuestionPreview, ResponseFailed } from "#browser/responses/schema.ts";
4
+ import { markdownPath } from "#files/markdownPath.ts";
5
+ import { metadataModel } from "#metadata/model.ts";
6
+ import { metadataParse } from "#metadata/parse.ts";
7
+ import type { Snapshot } from "#search/snapshot.ts";
8
+ import { questionId, revisionOf } from "./records.ts";
9
+
10
+ function reject(code: ResponseFailed["code"], message: string) {
11
+ return Effect.fail(new ResponseFailed({ code, message }));
12
+ }
13
+ export function locate(root: string, realRoot: string, snapshot: Effect.Effect<Snapshot, ResponseFailed>, fs: FileSystem.FileSystem) {
14
+ return Effect.fn("WorkBoard.locate")(function* (item: string, request: string) {
15
+ const data = yield* snapshot;
16
+ const entry = attentionRequests(data).entries.find((candidate) => candidate.itemId === item && candidate.request.id === request);
17
+ if (!entry) {
18
+ return yield* reject("Missing", "The exact open attention request is missing, ambiguous or unavailable.");
19
+ }
20
+ const real = yield* markdownPath(root, realRoot, entry.document.file);
21
+ if (!real) {
22
+ return yield* reject("Missing", "The reviewed source moved or disappeared.");
23
+ }
24
+ const source = yield* fs.readFileString(real).pipe(Effect.catch(() => reject("Unavailable", "The source could not be read.")));
25
+ if (new TextEncoder().encode(source).length > 256 * 1024) {
26
+ return yield* reject("Unsupported", "Question context is limited to 256 KiB; use a focused source document.");
27
+ }
28
+ const parsed = metadataParse(source);
29
+ if (
30
+ parsed.fields.id !== item
31
+ || parsed.fields.attention?.filter((candidate) => candidate.id === request && candidate.state === "open").length !== 1
32
+ ) {
33
+ return yield* reject("Stale", "The source request changed. Re-read the question before proceeding.");
34
+ }
35
+ const documents = [...data.documents.filter((document) => document.file !== entry.document.file), { file: entry.document.file, parsed }];
36
+ const model = metadataModel(documents, data.unavailable, data.model.rootFile);
37
+ const fresh = attentionRequests({ ...data, documents, model }).entries.find(
38
+ (candidate) => candidate.itemId === item && candidate.request.id === request,
39
+ )?.request;
40
+ if (!fresh) {
41
+ return yield* reject("Stale", "The current request or its references are invalid. Review the source diagnostics before registering it.");
42
+ }
43
+ const revision = revisionOf(source);
44
+ const question: QuestionPreview = {
45
+ context: source,
46
+ id: questionId(item, request, revision, entry.document.file),
47
+ item,
48
+ reason: fresh.reason,
49
+ request,
50
+ reviewedRevision: revision,
51
+ source: entry.document.file,
52
+ };
53
+ return question;
54
+ });
55
+ }
@@ -0,0 +1,15 @@
1
+ import type { Reading } from "#browser/responses/schema.ts";
2
+
3
+ export function outcome(reading: Reading, after: string | undefined, now: number) {
4
+ const at = after === undefined ? -1 : reading.responses.findIndex((candidate) => candidate.id === after);
5
+ if (after !== undefined && at < 0) {
6
+ return { kind: "missing_cursor" } as const;
7
+ }
8
+ const response = reading.responses[at + 1];
9
+ if (response) {
10
+ return { kind: "response", question: reading.question, response } as const;
11
+ }
12
+ return now >= reading.question.question.deadline
13
+ ? ({ kind: "unanswered", question: reading.question } as const)
14
+ : ({ kind: "pending", question: reading.question } as const);
15
+ }
@@ -0,0 +1,41 @@
1
+ import { Effect } from "effect";
2
+ import type { HttpServerRequest } from "effect/unstable/http";
3
+ import type { Changes } from "#files/changes.ts";
4
+ import { fileUrl } from "#files/url.ts";
5
+ import { respond } from "#http/respond.ts";
6
+ import { escapeHtml } from "#page/escape.ts";
7
+ import { navHtml } from "#page/nav.ts";
8
+ import { shell } from "#page/shell.ts";
9
+ import type { responseService } from "./service.ts";
10
+
11
+ export function responsePage(
12
+ service: Effect.Success<ReturnType<typeof responseService>>,
13
+ changes: Changes,
14
+ home: string | undefined,
15
+ enabled: boolean,
16
+ ) {
17
+ return Effect.fn("WorkBoard.responsePage")(function* (request: HttpServerRequest.HttpServerRequest) {
18
+ const params = new URL(request.url, "http://127.0.0.1").searchParams;
19
+ const item = params.get("item") ?? "";
20
+ const requestId = params.get("request") ?? "";
21
+ const files = yield* changes.files;
22
+ function layout(body: string, status = 200) {
23
+ return respond(shell("Respond", navHtml(files, "", home), body, changes.realRoot, false, undefined, "response"), "text/html", status);
24
+ }
25
+ return yield* service.locate(item, requestId).pipe(
26
+ Effect.flatMap((question) =>
27
+ Effect.map(
28
+ service
29
+ .read(question.id)
30
+ .pipe(Effect.catch((error) => (error.code === "Missing" ? Effect.succeed({ responses: [] }) : Effect.fail(error)))),
31
+ (recorded) => {
32
+ const q = question;
33
+ const body = `<section class="response-panel"><h1>Respond to ${escapeHtml(item)} / ${escapeHtml(requestId)}</h1><p>${escapeHtml(q.reason)}</p><p><a href="${fileUrl(q.source)}">Source: ${escapeHtml(q.source)}</a> · Reviewed SHA-256: <code>${q.reviewedRevision}</code></p><p>Saved replies are authored feedback. They do not close the request or establish acceptance.</p><details><summary>Exact source being reviewed</summary><pre>${escapeHtml(question.context)}</pre></details><form id="response-form" data-question="${question.id}" data-writable="${enabled}" data-item="${escapeHtml(item)}" data-request="${escapeHtml(requestId)}" data-revision="${q.reviewedRevision}"><label>Local author label <input name="author" maxlength="200" required></label><label>Response kind <select name="type"><option value="answer">Answer (including no)</option><option value="clarify">Ask back / clarify</option><option value="not_now">Not now</option></select></label><label>Your response <textarea name="body" rows="8" maxlength="32768" required></textarea></label><p>Drafts stay in this browser for 30 days after the last edit, with a 2 MiB workspace limit. Saved responses are Markdown files in responses/.</p><button id="response-preview" type="button" disabled>Preview Markdown</button><pre id="response-preview-content" hidden></pre><button id="response-submit" type="submit" disabled>Record response</button><button id="response-clear" type="button" disabled>Clear workspace drafts</button><p id="response-status" role="status">${enabled ? "JavaScript is required to record a response." : "Read-only mode. Restart with --responses to explicitly enable local writes."}</p></form><section id="response-history"><h2>Recorded responses</h2>${recorded.responses.map((record) => `<article><p><a href="/responses/${record.id}.md">${escapeHtml(record.response.author)} · ${escapeHtml(record.response.type)}</a></p><pre>${escapeHtml(record.body)}</pre></article>`).join("") || "<p>No response is recorded for this source revision.</p>"}</section></section>`;
34
+ return layout(body);
35
+ },
36
+ ),
37
+ ),
38
+ Effect.catch((error) => Effect.succeed(layout(`<h1>Response unavailable</h1><p>${escapeHtml(error.message)}</p>`, 409))),
39
+ );
40
+ });
41
+ }
@@ -0,0 +1,53 @@
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open, realpath } from "node:fs/promises";
3
+ import { Effect } from "effect";
4
+ import { ResponseFailed } from "#browser/responses/schema.ts";
5
+ import { publishIn } from "./publishIn.ts";
6
+
7
+ function failure(cause: unknown): ResponseFailed {
8
+ return cause instanceof ResponseFailed
9
+ ? cause
10
+ : new ResponseFailed({
11
+ code: "Unavailable",
12
+ message: "The response could not be recorded. Keep the draft and retry after checking local filesystem access.",
13
+ });
14
+ }
15
+ export function publish(root: string, realRoot: string, id: string, content: string) {
16
+ return Effect.tryPromise({
17
+ catch: failure,
18
+ try: async () => {
19
+ if (/^[a-zA-Z\d][a-zA-Z\d._-]*$/u.exec(id) === null) {
20
+ throw new ResponseFailed({ code: "Conflict", message: "Invalid response identity." });
21
+ }
22
+ if (process.platform !== "linux") {
23
+ throw new ResponseFailed({
24
+ code: "Unsupported",
25
+ message: "Safe response publication currently requires Linux directory descriptors. Reading remains available.",
26
+ });
27
+ }
28
+ const directory = await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
29
+ try {
30
+ const base = `/proc/self/fd/${directory.fd}`;
31
+ if ((await realpath(base)) !== realRoot || (await realpath(root)) !== realRoot) {
32
+ throw new ResponseFailed({ code: "Conflict", message: "The workspace root changed. Restart against the intended root." });
33
+ }
34
+ try {
35
+ await mkdir(`${base}/responses`);
36
+ } catch (cause) {
37
+ if (!(cause instanceof Error && "code" in cause && cause.code === "EEXIST")) {
38
+ throw cause;
39
+ }
40
+ }
41
+ await directory.sync();
42
+ const target = await open(`${base}/responses`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
43
+ try {
44
+ await publishIn(target, root, realRoot, id, content);
45
+ } finally {
46
+ await target.close();
47
+ }
48
+ } finally {
49
+ await directory.close();
50
+ }
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,64 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { type FileHandle, link, open, readFile, realpath, unlink } from "node:fs/promises";
4
+ import { ResponseFailed } from "#browser/responses/schema.ts";
5
+
6
+ function uncertain(cause: unknown) {
7
+ return new ResponseFailed({ cause, code: "Uncertain", message: "Publication may have completed. Re-read the response identity before retrying." });
8
+ }
9
+ function exists(cause: unknown) {
10
+ return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
11
+ }
12
+ async function linkOrReconcile(temporary: string, name: string, content: string): Promise<boolean> {
13
+ try {
14
+ await link(temporary, name);
15
+ return true;
16
+ } catch (cause) {
17
+ if (!exists(cause)) {
18
+ throw cause;
19
+ }
20
+ }
21
+ const existing = await open(name, constants.O_RDONLY | constants.O_NOFOLLOW);
22
+ try {
23
+ if ((await readFile(existing, "utf8")) !== content) {
24
+ throw new ResponseFailed({ code: "Conflict", message: "This identity already records different content; no file was replaced." });
25
+ }
26
+ } finally {
27
+ await existing.close();
28
+ }
29
+ return false;
30
+ }
31
+ export async function publishIn(target: FileHandle, root: string, realRoot: string, id: string, content: string) {
32
+ const folder = `/proc/self/fd/${target.fd}`;
33
+ const expected = `${realRoot}/responses`;
34
+ async function validate() {
35
+ if ((await realpath(folder)) !== expected || (await realpath(root)) !== realRoot) {
36
+ throw new ResponseFailed({ code: "Conflict", message: "The write boundary changed." });
37
+ }
38
+ }
39
+ await validate();
40
+ const name = `${folder}/${id}.md`;
41
+ const temporary = `${folder}/.${randomUUID()}.tmp`;
42
+ let published = false;
43
+ try {
44
+ const file = await open(temporary, "wx", 0o600);
45
+ try {
46
+ await file.writeFile(content, "utf8");
47
+ await file.sync();
48
+ } finally {
49
+ await file.close();
50
+ }
51
+ await validate();
52
+ await linkOrReconcile(temporary, name, content);
53
+ published = true;
54
+ await target.sync();
55
+ await validate();
56
+ } catch (cause) {
57
+ if (published) {
58
+ throw uncertain(cause);
59
+ }
60
+ throw cause;
61
+ } finally {
62
+ await unlink(temporary).catch(() => undefined);
63
+ }
64
+ }
@@ -0,0 +1,45 @@
1
+ import { Effect } from "effect";
2
+ import { ResponseFailed } from "#browser/responses/schema.ts";
3
+ import type { MetadataDocument } from "#metadata/model.ts";
4
+ import type { Snapshot } from "#search/snapshot.ts";
5
+ import { malformedResponse, questionFrom, responsesFor } from "./records.ts";
6
+
7
+ function reject(code: ResponseFailed["code"], message: string) {
8
+ return Effect.fail(new ResponseFailed({ code, message }));
9
+ }
10
+ const registeredQuestion = Effect.fn("WorkBoard.registeredQuestion")(function* (matches: readonly MetadataDocument[] | undefined) {
11
+ if (!matches?.length) {
12
+ return yield* reject("Missing", "The registered question is missing.");
13
+ }
14
+ if (matches.length !== 1) {
15
+ return yield* reject("Conflict", "The registered question identity is ambiguous; response history is unknown.");
16
+ }
17
+ const question = matches[0] && questionFrom(matches[0]);
18
+ if (!question) {
19
+ return yield* reject("Conflict", "This identity is not a valid registered question; response history is unknown.");
20
+ }
21
+ return question;
22
+ });
23
+ export function readResponses(snapshot: Effect.Effect<Snapshot, ResponseFailed>) {
24
+ return Effect.fn("WorkBoard.read")(function* (id: string) {
25
+ const data = yield* snapshot;
26
+ if (data.unavailable.length > 0) {
27
+ return yield* reject("Unavailable", "The workspace index is incomplete; no response can be selected safely.");
28
+ }
29
+ const question = yield* registeredQuestion(data.model.ids.get(id));
30
+ if (new TextEncoder().encode(question.context).length > 256 * 1024) {
31
+ return yield* reject("Unsupported", "The registered context exceeds the 256 KiB limit.");
32
+ }
33
+ if (data.documents.some(malformedResponse)) {
34
+ return yield* reject("Unavailable", "A response record is malformed; response history cannot be established safely.");
35
+ }
36
+ const result = responsesFor(data.documents, question);
37
+ if (result.responses.some((candidate) => candidate.body.length > 32_768)) {
38
+ return yield* reject("Unsupported", "A recorded response exceeds the supported text limit; read its source file directly.");
39
+ }
40
+ if (result.responses.some((candidate) => data.model.ids.get(candidate.id)?.length !== 1)) {
41
+ return yield* reject("Conflict", "A response identity is duplicated; no answer is selected.");
42
+ }
43
+ return result;
44
+ });
45
+ }
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto";
2
+ import { stringify } from "yaml";
3
+ import type { DraftInput, Question, Reading, RecordedResponse } from "#browser/responses/schema.ts";
4
+ import type { MetadataDocument } from "#metadata/model.ts";
5
+
6
+ export function revisionOf(source: string) {
7
+ return createHash("sha256").update(source).digest("hex");
8
+ }
9
+ export function questionId(item: string, request: string, revision: string, source: string) {
10
+ return `question.${revisionOf(JSON.stringify([item, request, revision, source]))}`;
11
+ }
12
+ export function questionMarkdown(record: Question): string {
13
+ return `---\n${stringify({ id: record.id, kind: "question", question: record.question })}---\n${record.context}`;
14
+ }
15
+ export function responseMarkdown(record: RecordedResponse): string {
16
+ return `---\n${stringify({ id: record.id, kind: "response", response: record.response })}---\n${record.body}`;
17
+ }
18
+ export function questionFrom(document: MetadataDocument): Question | undefined {
19
+ const { id, kind, question } = document.parsed.fields;
20
+ return id
21
+ && kind === "question"
22
+ && question
23
+ && question.reviewedRevision === revisionOf(document.parsed.body)
24
+ && id === questionId(question.item, question.request, question.reviewedRevision, question.source)
25
+ ? { context: document.parsed.body, id, question }
26
+ : undefined;
27
+ }
28
+ export function responseFrom(document: MetadataDocument): RecordedResponse | undefined {
29
+ const { id, kind, response } = document.parsed.fields;
30
+ return id && kind === "response" && response ? { body: document.parsed.body, id, response } : undefined;
31
+ }
32
+ export function responsesFor(documents: readonly MetadataDocument[], question: Question): Reading {
33
+ const responses = documents.flatMap((document) => {
34
+ const record = responseFrom(document);
35
+ return record?.response.question === question.id && record.response.reviewedRevision === question.question.reviewedRevision ? [record] : [];
36
+ });
37
+ responses.sort((a, b) => a.response.recordedAt - b.response.recordedAt || a.id.localeCompare(b.id));
38
+ return { question, responses };
39
+ }
40
+
41
+ export function matchesResponse(record: RecordedResponse, input: DraftInput) {
42
+ return (
43
+ record.body === input.body
44
+ && record.response.author === input.author
45
+ && record.response.type === input.type
46
+ && record.response.question === input.question
47
+ );
48
+ }
49
+
50
+ export function malformedResponse(document: MetadataDocument) {
51
+ const fields = document.parsed.fields;
52
+ return fields.kind === "response" && !(fields.id && fields.response);
53
+ }
@@ -0,0 +1,23 @@
1
+ import { Effect } from "effect";
2
+ import { type DraftInput, type RecordedResponse, ResponseFailed } from "#browser/responses/schema.ts";
3
+ import type { Snapshot } from "#search/snapshot.ts";
4
+ import { publish } from "./publish.ts";
5
+ import { matchesResponse, responseMarkdown } from "./records.ts";
6
+ import type { ResponseOptions } from "./service.ts";
7
+
8
+ export const retryResponse = Effect.fn("WorkBoard.retryResponse")(function* (
9
+ existing: RecordedResponse,
10
+ input: DraftInput,
11
+ snapshot: Effect.Effect<Snapshot, ResponseFailed>,
12
+ options: ResponseOptions,
13
+ ) {
14
+ if (!matchesResponse(existing, input)) {
15
+ return yield* Effect.fail(new ResponseFailed({ code: "Conflict", message: "The response identity already records different content." }));
16
+ }
17
+ if ((yield* snapshot).model.ids.get(input.id)?.[0]?.file !== `responses/${input.id}.md`) {
18
+ return yield* Effect.fail(
19
+ new ResponseFailed({ code: "Conflict", message: "The saved response moved. Read its existing record instead of retrying a write." }),
20
+ );
21
+ }
22
+ yield* publish(options.root, options.changes.realRoot, existing.id, responseMarkdown(existing));
23
+ });