gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commita6af7883a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mrea6af7883/lib/report.hl

31.0 KB

  1. // report.hl — `report <file> [--post]`: validate a worker's JSON report (docs/scheduler-agent.md §4)
  2. // and, with --post, file it.
  3. //
  4. // VALIDATION (prints every problem, one line each, with its JSON path):
  5. // the file is one JSON object; exactly the keys ticket, session, result, complete, test, done, verified, open, issues,
  6. // questions, running, refused — all present (lists may be empty), no others;
  7. // (mission 026) complete = true | false: true only if the ticket's goal is reached AND what the creator should test is
  8. // really in place (e.g. copied into the app, not only a work copy);
  9. // (mission 023, README "Ticket texts are written for the creator") result = ONE plain sentence, one line,
  10. // ≤ 200 characters; test = 1–4 steps, each one line ≤ 200 characters — they ARE the ticket comment;
  11. // ticket = "<project>#<n>"; session a non-empty string; done/open/questions/refused lists of
  12. // non-empty strings; verified[] = { claim, command, output } all non-empty strings (no output →
  13. // refused); issues[] = { project, subject, repro, observed, expected: non-empty strings, blocks:
  14. // boolean }; running[] = { what: non-empty, url, log: strings, pid: number }; no unknown keys
  15. // anywhere. An issues[].project that is not in the registry is NOT a refusal: it is reported
  16. // and becomes a creator question on --post (concept: "else → creator's inbox").
  17. //
  18. // --post (idempotent per report; the key is the session, the report's sha256 proves "the same"):
  19. // 1. the ticket must exist; if it already has a comment carrying `colony-report: <session> ·`
  20. // with the SAME sha → the comment and the state are not touched again; a DIFFERENT sha →
  21. // refused (one session, one report);
  22. // 2. every issue → a ticket in its project (source `colony-report:<session>:issue:<i>`, so a
  23. // re-post answers the existing ticket and writes nothing), summary links back;
  24. // an unknown project → a creator question (project antcolony, `awaiting creator`, source
  25. // `colony-report:<session>:question:<i>`);
  26. // 3. ONE short comment on the ticket (mission 023): result · **Test:** steps · ≤ 3 detail bullets (questions,
  27. // filed problems, not done, running, refused — what matters most first) · ONE machine line
  28. // `colony-report: <session> · sha256 <12 hex>[ · controller pass]`; the full report stays in runs/<session>/;
  29. // 2b. (mission 022) an issue with `blocks: true` → the ticket gets it as a blocker (`blocked-by`, once);
  30. // every `questions` entry → its own ticket in the TICKET's project, `awaiting creator`, parent = the
  31. // ticket (source `colony-report:<session>:questions:<i>`); the comment links them;
  32. // 4. the ticket → `awaiting creator` (never `confirmed`), only when the comment was new in this
  33. // run and the ticket is not already there / closed.
  34. // (mission 027) result ≤ 120 characters; test = 2–4 steps of ≤ 90 characters, no work copy (`.scratch`), on a project with
  35. // `live` no local address; questions ≤ 3, each ≤ 3 lines / 300 characters; `decided` (optional key, one line ≤ 120 each)
  36. // = small choices the worker made itself → ONE detail bullet "Decided: …". The Test steps are a numbered list.
  37. // DEPLOY GATE: a complete report on a project with `live` → "Built — goes live with the next deploy." + marker
  38. // `· ready to deploy`, the ticket stays `in progress` (deploy.hl: `./colony ready`, `./colony deployed`).
  39. // (mission 026) `complete: false` = HALF DONE: the comment is "Half done: <result>; the next worker continues." + what is
  40. // still open (no Test steps; machine line ends `· half done`), and the ticket goes back to `open` (only from
  41. // `in progress`, i.e. the lease) — never `awaiting creator`. Questions / issues are filed as usual.
  42. import { sha256 } from 'hl:crypto'
  43. import { loadRegistry } from './registry.hl'
  44. import { getJson, postJson, readToken, ticketPath, pageUrl, fileQuestion, fileQuestionIn } from './tickets.hl'
  45. import { NL, readJson, fromCwd, isList, isObject, isString, nonEmpty, positionals } from './util.hl'
  46. static TOP = ['ticket' 'session' 'result' 'complete' 'test' 'done' 'verified' 'open' 'issues' 'questions' 'decided' 'running' 'refused']
  47. // mission 027: `decided` may be missing (reports written before it) — it counts as []
  48. static OPTIONAL = ['decided']
  49. // mission 027 (first live batch: run-on test lines, work-copy paths, too many / too long questions)
  50. static RESULT_MAX = 120
  51. static STEP_MAX = 90
  52. static TEST_MIN = 2
  53. static TEST_MAX = 4
  54. static DECIDED_MAX = 120
  55. static QUESTION_LINES = 3
  56. static QUESTION_CHARS = 300
  57. static QUESTION_TITLE_MAX = 80
  58. static STRING_LISTS = ['done' 'open' 'questions' 'decided' 'refused']
  59. static VERIFIED = ['claim' 'command' 'output']
  60. static ISSUE = ['project' 'subject' 'repro' 'observed' 'expected' 'blocks']
  61. static ISSUE_TEXT = ['project' 'subject' 'repro' 'observed' 'expected']
  62. static RUNNING = ['what' 'url' 'pid' 'log']
  63. static typeOf = (v) => {
  64. if (v == null) { return 'null' }
  65. if (isList(v)) { return 'list' }
  66. if (isObject(v)) { return 'object' }
  67. return hlTypeName(v).toLowerCase()
  68. }
  69. // "<project>#<n>" → { project, number } or null
  70. static parseRef = (s) => {
  71. let h = s.indexOf('#')
  72. if (h <= 0 || h == s.length - 1) { return null }
  73. let p = s.slice(0, h)
  74. let n = s.slice(h + 1)
  75. let i = 0
  76. while (i < n.length) {
  77. let c = n.charCodeAt(i)
  78. if (c < 48 || c > 57) { return null }
  79. i = i + 1
  80. }
  81. if (n.startsWith('0')) { return null }
  82. return { project = p number = n }
  83. }
  84. // unknown keys of an object entry → problems
  85. static unknownKeys = (obj, allowed, path, problems) => {
  86. for (k of obj.keys()) {
  87. if (!allowed.includes(k)) { problems.push(path + ": unknown field '" + k + "' (allowed: " + allowed.join(', ') + ')') }
  88. }
  89. }
  90. // a one-line text for the creator (at most `max` characters) → the problem or null
  91. static lineProblem = (v, max) => {
  92. if (!isString(v)) { return 'must be a string, not a ' + typeOf(v) }
  93. if (v.trim() == '') { return 'must not be empty' }
  94. if (v.includes(NL)) { return 'must be ONE line (no line breaks)' }
  95. if (v.length > max) { return 'is ' + v.length + ' characters long, at most ' + max }
  96. return null
  97. }
  98. // mission 027: the registry entry of the report's project (null when unknown / no ticket)
  99. static projectOf = (report, reg) => {
  100. if (!isString(report.ticket)) { return null }
  101. let pr = parseRef(report.ticket)
  102. if (pr == null) { return null }
  103. return reg.projects[pr.project]
  104. }
  105. // mission 027: a test step is for the creator on the REAL thing — never a work copy; on a project with a live site
  106. // never a local address → the problem or null
  107. static stepPlaceProblem = (v, m) => {
  108. let low = v.toLowerCase()
  109. if (low.includes('.scratch')) { return 'names a work copy (.scratch) — the creator tests the real thing' + (m != null && m.live != null && m.live.url != null ? ' on ' + m.live.url : '') }
  110. if (m != null && m.live != null && (low.includes('localhost') || low.includes('127.0.0.1') || low.includes('0.0.0.0'))) { return 'names a local address — write the step for the live site ' + (m.live.url != null ? m.live.url : '') + ' (the creator tests after the deploy)' }
  111. return null
  112. }
  113. // → { problems: [text], notes: [text], report }
  114. static validate = (report, reg) => {
  115. let problems = []
  116. let notes = []
  117. if (!isObject(report)) {
  118. problems.push('the report must be one JSON object, not a ' + typeOf(report))
  119. return { problems = problems notes = notes }
  120. }
  121. unknownKeys(report, TOP, '(top)', problems)
  122. for (k of TOP) {
  123. if (report.keys().includes(k) == false) { if (!OPTIONAL.includes(k)) { problems.push("(top): field '" + k + "' is missing (every field is required; use [] for an empty list)") } }
  124. else if (report[k] == null) { problems.push(k + ': must not be null' + (k == 'ticket' || k == 'session' ? '' : ' (use [] for an empty list)')) }
  125. }
  126. // ticket, session
  127. if (report.ticket != null) {
  128. if (!isString(report.ticket)) { problems.push('ticket: must be a string "<project>#<number>", not a ' + typeOf(report.ticket)) }
  129. else if (parseRef(report.ticket) == null) { problems.push('ticket: "' + report.ticket + '" is not "<project>#<number>" (e.g. "tickets.worldapi.org#9")') }
  130. }
  131. if (report.session != null && !nonEmpty(report.session)) { problems.push('session: must be a non-empty string') }
  132. // mission 026: finished or not
  133. if (report.complete != null && hlTypeName(report.complete) != 'Boolean') { problems.push('complete: must be true or false, not a ' + typeOf(report.complete) + ' (true only if the goal is reached AND what the creator should test is really in place)') }
  134. // mission 023: result + test are what the creator reads — short, plain, one line each
  135. // mission 027: result ≤ 120 characters; test = 2–4 steps of ≤ 90 characters each, for the creator on the live site
  136. if (report.result != null) {
  137. let lp = lineProblem(report.result, RESULT_MAX)
  138. if (lp != null) { problems.push('result: ' + lp + ' (ONE plain sentence: what is done)') }
  139. }
  140. let pm = projectOf(report, reg)
  141. let tst = report.test
  142. if (tst != null) {
  143. if (!isList(tst)) { problems.push('test: must be a list of ' + TEST_MIN + '–' + TEST_MAX + ' short steps, not a ' + typeOf(tst)) }
  144. else if (tst.length < TEST_MIN || tst.length > TEST_MAX) { problems.push('test: must have ' + TEST_MIN + '–' + TEST_MAX + ' steps, has ' + tst.length) }
  145. else {
  146. let ti = 0
  147. while (ti < tst.length) {
  148. let tp = lineProblem(tst[ti], STEP_MAX)
  149. if (tp == null) { tp = stepPlaceProblem(tst[ti], pm) }
  150. if (tp != null) { problems.push('test[' + ti + ']: ' + tp) }
  151. ti = ti + 1
  152. }
  153. }
  154. }
  155. // mission 027: short questions (antcolony#24: no count limit — the check after the job rules on each); small choices go to `decided` (one line each)
  156. let qv = report.questions
  157. if (qv != null && isList(qv)) {
  158. let qi = 0
  159. while (qi < qv.length) {
  160. // antcolony#23: an entry is { title, text } (title = the short subject the worker writes); a plain string (old reports) still counts as the text
  161. let qe = qv[qi]
  162. let qx = qe
  163. if (isObject(qe)) {
  164. unknownKeys(qe, ['title' 'text'], 'questions[' + qi + ']', problems)
  165. qx = qe.text
  166. if (!nonEmpty(qe.title)) { problems.push('questions[' + qi + ']: title must be a non-empty string (a short subject, e.g. "how long should a login last?")') }
  167. else {
  168. let tp = lineProblem(qe.title, QUESTION_TITLE_MAX)
  169. if (tp != null) { problems.push('questions[' + qi + '].title: ' + tp) }
  170. }
  171. }
  172. if (nonEmpty(qx)) {
  173. let nl = 0
  174. for (l of qx.trim().split(NL)) { if (l.trim() != '') { nl = nl + 1 } }
  175. if (nl > QUESTION_LINES) { problems.push('questions[' + qi + ']: has ' + nl + ' lines, at most ' + QUESTION_LINES + ' (the question, then the options)') }
  176. if (qx.length > QUESTION_CHARS) { problems.push('questions[' + qi + ']: is ' + qx.length + ' characters long, at most ' + QUESTION_CHARS) }
  177. } else if (isObject(qe)) { problems.push('questions[' + qi + ']: text must be a non-empty string') }
  178. qi = qi + 1
  179. }
  180. }
  181. let dv = report.decided
  182. if (dv != null && isList(dv)) {
  183. let di = 0
  184. while (di < dv.length) {
  185. if (isString(dv[di]) && dv[di].trim() != '') {
  186. let dp = lineProblem(dv[di], DECIDED_MAX)
  187. if (dp != null) { problems.push('decided[' + di + ']: ' + dp) }
  188. }
  189. di = di + 1
  190. }
  191. }
  192. // lists of strings
  193. for (k of STRING_LISTS) {
  194. let v = report[k]
  195. if (v != null) {
  196. if (!isList(v)) { problems.push(k + ': must be a list of strings, not a ' + typeOf(v)) } else {
  197. let i = 0
  198. while (i < v.length) {
  199. if (k == 'questions' && isObject(v[i])) { } else if (!nonEmpty(v[i])) { problems.push(k + '[' + i + ']: must be a non-empty string, not ' + (isString(v[i]) ? 'an empty one' : 'a ' + typeOf(v[i]))) }
  200. i = i + 1
  201. }
  202. }
  203. }
  204. }
  205. // verified
  206. let v = report.verified
  207. if (v != null) {
  208. if (!isList(v)) { problems.push('verified: must be a list of { claim, command, output }, not a ' + typeOf(v)) } else {
  209. let i = 0
  210. while (i < v.length) {
  211. let e = v[i]
  212. let at = 'verified[' + i + ']'
  213. if (!isObject(e)) { problems.push(at + ': must be an object { claim, command, output }, not a ' + typeOf(e)) } else {
  214. unknownKeys(e, VERIFIED, at, problems)
  215. for (f of VERIFIED) {
  216. if (e[f] == null) { problems.push(at + ": '" + f + "' is missing" + (f == 'claim' ? '' : ' (every verified entry needs command AND output)')) }
  217. else if (!isString(e[f])) { problems.push(at + ": '" + f + "' must be a string, not a " + typeOf(e[f])) }
  218. else if (e[f].trim() == '') { problems.push(at + ": '" + f + "' is empty" + (f == 'claim' ? '' : ' (every verified entry needs command AND output)')) }
  219. }
  220. }
  221. i = i + 1
  222. }
  223. }
  224. }
  225. // issues
  226. let is = report.issues
  227. if (is != null) {
  228. if (!isList(is)) { problems.push('issues: must be a list of { project, subject, repro, observed, expected, blocks }, not a ' + typeOf(is)) } else {
  229. let i = 0
  230. while (i < is.length) {
  231. let e = is[i]
  232. let at = 'issues[' + i + ']'
  233. if (!isObject(e)) { problems.push(at + ': must be an object, not a ' + typeOf(e)) } else {
  234. unknownKeys(e, ISSUE, at, problems)
  235. for (f of ISSUE_TEXT) {
  236. if (e[f] == null) { problems.push(at + ": '" + f + "' is missing") }
  237. else if (!isString(e[f])) { problems.push(at + ": '" + f + "' must be a string, not a " + typeOf(e[f])) }
  238. else if (e[f].trim() == '') { problems.push(at + ": '" + f + "' is empty") }
  239. }
  240. if (e.blocks == null) { problems.push(at + ": 'blocks' is missing (true or false)") }
  241. else if (hlTypeName(e.blocks) != 'Boolean') { problems.push(at + ": 'blocks' must be true or false, not a " + typeOf(e.blocks)) }
  242. if (nonEmpty(e.project) && reg.projects[e.project] == null) {
  243. notes.push(at + ": project '" + e.project + "' is not in the registry (" + reg.names.join(', ') + ') → becomes a creator question on --post')
  244. }
  245. }
  246. i = i + 1
  247. }
  248. }
  249. }
  250. // running
  251. let rn = report.running
  252. if (rn != null) {
  253. if (!isList(rn)) { problems.push('running: must be a list of { what, url, pid, log }, not a ' + typeOf(rn)) } else {
  254. let i = 0
  255. while (i < rn.length) {
  256. let e = rn[i]
  257. let at = 'running[' + i + ']'
  258. if (!isObject(e)) { problems.push(at + ': must be an object, not a ' + typeOf(e)) } else {
  259. unknownKeys(e, RUNNING, at, problems)
  260. if (e.what == null) { problems.push(at + ": 'what' is missing") } else if (!nonEmpty(e.what)) { problems.push(at + ": 'what' must be a non-empty string") }
  261. for (f of ['url' 'log']) {
  262. if (e[f] == null) { problems.push(at + ": '" + f + "' is missing (\"\" when there is none)") } else if (!isString(e[f])) { problems.push(at + ": '" + f + "' must be a string, not a " + typeOf(e[f])) }
  263. }
  264. if (e.pid == null) { problems.push(at + ": 'pid' is missing") } else if (hlTypeName(e.pid) != 'Number') { problems.push(at + ": 'pid' must be a number, not a " + typeOf(e.pid)) }
  265. }
  266. i = i + 1
  267. }
  268. }
  269. }
  270. return { problems = problems notes = notes }
  271. }
  272. // ---- the texts for the creator (mission 023, README "Ticket texts are written for the creator") ----------
  273. // the first line of a text, at most n characters
  274. static oneLine = (s, n) => {
  275. let t = s.trim()
  276. let nl = t.indexOf(NL)
  277. if (nl > 0) { t = t.slice(0, nl).trim() }
  278. if (t.length > n) { t = t.slice(0, n - 1).trim() + '…' }
  279. return t
  280. }
  281. // `alpha#3` → `alpha #3` (the way tickets shows a ticket, mission 023: one link style everywhere)
  282. static refLabel = (ref) => { return ref.replaceAll('#', ' #') }
  283. // a ticket row → `[alpha #7](url)`
  284. static linkOf = (t) => { return '[' + t.project + ' ' + t.ref + '](' + pageUrl(t) + ')' }
  285. // the report comment: result · **Test:** steps · ≤ 3 detail bullets · the machine line (hash null → none).
  286. // filed = [{ link, blocks, unknown }] (the report's issues, filed), asked = [link] (the question tickets),
  287. // note = text for the end of the machine line (e.g. 'controller pass') or null
  288. // (mission 026) a HALF DONE report (`complete: false`): "Half done: <result>; the next worker continues." + what is still
  289. // open first, no Test steps (nothing is ready to test), machine line ending `· half done`
  290. static isHalfDone = (r) => { return r.complete == false }
  291. static withoutDot = (s) => {
  292. let t = s.trim()
  293. while (t.endsWith('.')) { t = t.slice(0, t.length - 1).trim() }
  294. return t
  295. }
  296. // mission 027: the small choices the worker made itself (`decided`, may be missing in older reports)
  297. static decidedOf = (r) => { return r.decided == null ? [] : r.decided }
  298. // mission 027: the Test steps as a numbered Markdown list (was one run-on line "1) … 2) …")
  299. static testLines = (r) => {
  300. let o = ['**Test:**']
  301. let i = 0
  302. while (i < r.test.length) {
  303. o.push((i + 1) + '. ' + r.test[i].trim())
  304. i = i + 1
  305. }
  306. return o
  307. }
  308. // the detail bullets, what matters most first (the caller keeps ≤ 3): [half: still open] · questions · problems filed
  309. // elsewhere · (mission 027) "Decided: …" · [not half: not done] · still running · refused
  310. static detailsOf = (r, filed, asked, half) => {
  311. let d = []
  312. if (half && r.open.length > 0) { d.push('Still open: ' + oneLine(r.open[0], 90) + (r.open.length > 1 ? ' (and ' + (r.open.length - 1) + ' more)' : '')) }
  313. if (asked.length > 0) { d.push((asked.length == 1 ? 'A question for you: ' : 'Questions for you: ') + asked.join(', ')) }
  314. if (filed.length > 0) {
  315. let parts = []
  316. for (f of filed) { parts.push(f.link + (f.blocks ? ' (blocks this ticket)' : '') + (f.unknown ? ' (where should it go?)' : '')) }
  317. d.push((filed.length == 1 ? 'A problem found elsewhere: ' : 'Problems found elsewhere: ') + parts.join(', '))
  318. }
  319. let dc = decidedOf(r)
  320. if (dc.length > 0) {
  321. let ds = []
  322. for (x of dc) { ds.push(withoutDot(oneLine(x, 120))) }
  323. d.push(oneLine('Decided: ' + ds.join('; ') + '.', 200))
  324. }
  325. if (!half && r.open.length > 0) { d.push('Not done: ' + oneLine(r.open[0], 90) + (r.open.length > 1 ? ' (and ' + (r.open.length - 1) + ' more)' : '')) }
  326. for (x of r.running) { d.push('Still running: ' + oneLine(x.what, 60) + (x.url != '' ? ' — ' + x.url : '')) }
  327. if (r.refused.length > 0) { d.push('The safety check refused ' + r.refused.length + ' action' + (r.refused.length == 1 ? '' : 's') + '.') }
  328. return d
  329. }
  330. static pushDetails = (o, d) => {
  331. if (d.length == 0) { return o }
  332. o.push('')
  333. let k = 0
  334. while (k < d.length && k < 3) {
  335. o.push('- ' + d[k])
  336. k = k + 1
  337. }
  338. return o
  339. }
  340. static commentOf = (r, filed, asked, hash, note) => {
  341. let o = []
  342. let half = isHalfDone(r)
  343. if (half) {
  344. o.push('Half done: ' + withoutDot(r.result) + '; the next worker continues.')
  345. } else {
  346. o.push(r.result.trim())
  347. o.push('')
  348. for (l of testLines(r)) { o.push(l) }
  349. }
  350. pushDetails(o, detailsOf(r, filed, asked, half))
  351. if (hash != null) {
  352. o.push('')
  353. o.push('colony-report: ' + r.session + ' · sha256 ' + hash + (note == null ? '' : ' · ' + note) + (half ? ' · half done' : ''))
  354. }
  355. return o.join(NL)
  356. }
  357. // ---- mission 027: the deploy gate ---------------------------------------------------------------------------------
  358. // A project whose registry entry has `live` is tested by the creator ON THE LIVE SITE, so a complete + passed report is
  359. // not "ready for you to test" before the deploy: ONE line "Built — goes live with the next deploy." + the machine line
  360. // (marker ` · ready to deploy`), the ticket stays `in progress` (out of `next`; lease.hl readyOf: never expires).
  361. // `./colony ready` lists them; `./colony deployed <project> <n>` (deploy.hl) posts the readable comment (deployedCommentOf)
  362. // and sets `awaiting creator`. Projects without `live` (e.g. research tickets) → `awaiting creator` at once, as before.
  363. static READY_NOTE = 'ready to deploy'
  364. static deployGate = (r, reg) => {
  365. if (isHalfDone(r)) { return false }
  366. let m = projectOf(r, reg)
  367. return m != null && m.live != null
  368. }
  369. static readyCommentOf = (r, hash, note) => {
  370. return 'Built — goes live with the next deploy.' + NL + NL + 'colony-report: ' + r.session + ' · sha256 ' + hash + (note == null ? '' : ' · ' + note) + ' · ' + READY_NOTE
  371. }
  372. // the site's name for the creator: `https://ident.worldapi.org/x` → `ident.worldapi.org`
  373. static siteName = (url) => {
  374. let u = url
  375. let i = u.indexOf('://')
  376. if (i >= 0) { u = u.slice(i + 3) }
  377. let j = u.indexOf('/')
  378. if (j >= 0) { u = u.slice(0, j) }
  379. return u
  380. }
  381. // after the deploy: "Now live on <site> — <result>" · **Test:** 1. … · ≤ 3 details · `colony-deployed: S · sha256 H`
  382. static deployedCommentOf = (r, filed, asked, site, hash) => {
  383. let o = []
  384. o.push('Now live on ' + site + ' — ' + r.result.trim())
  385. o.push('')
  386. for (l of testLines(r)) { o.push(l) }
  387. pushDetails(o, detailsOf(r, filed, asked, false))
  388. o.push('')
  389. o.push('colony-deployed: ' + r.session + ' · sha256 ' + hash)
  390. return o.join(NL)
  391. }
  392. // an issue filed in another project
  393. static issueSummary = (e, ticketRef, ticketUrl) => {
  394. let o = []
  395. o.push('Found while working on [' + refLabel(ticketRef) + '](' + ticketUrl + ')' + (e.blocks ? ' — it blocks that ticket.' : '.'))
  396. o.push('')
  397. o.push('**Repro:** ' + e.repro.trim())
  398. o.push('')
  399. o.push('**Observed:** ' + e.observed.trim())
  400. o.push('')
  401. o.push('**Expected:** ' + e.expected.trim())
  402. return o.join(NL)
  403. }
  404. // a creator question's subject: `Question (alpha#3): <its first line, ≤ 100 characters>`
  405. // antcolony#23: the worker's own short title (`{ title, text }`); an old plain-string entry falls back to its first line
  406. static questionSubject = (ticketRef, q) => {
  407. if (isObject(q)) { return 'Question (' + ticketRef + '): ' + oneLine(q.title, 100) }
  408. return 'Question (' + ticketRef + '): ' + oneLine(q, 100)
  409. }
  410. static questionText = (q) => {
  411. return isObject(q) ? q.text : q
  412. }
  413. static questionSummary = (q, r, ticketUrl) => {
  414. return q.trim() + NL + NL + 'Asked while working on [' + refLabel(r.ticket) + '](' + ticketUrl + '). **Answer** with a comment here — the next worker reads it.'
  415. }
  416. // ---- the command ----------------------------------------------------------------------------
  417. static runReport = (argv) => {
  418. let pos = positionals(argv)
  419. if (pos.length != 2) {
  420. console.log('usage: report <file> [--post]')
  421. return false
  422. }
  423. return reportFile(fromCwd(pos[1]), argv.includes('--post'), null).valid
  424. }
  425. // validate (and with `post` file) one report file — shared by `report` and `work`.
  426. // `expect` = { ticket, session } (from `work`: the report must be for THAT ticket and session) or null.
  427. // → { valid, posted, problems } (posted: --post ran to POSTED)
  428. static reportFile = (file, post, expect) => {
  429. let reg = loadRegistry()
  430. for (e of reg.errors) { console.log('registry: ' + e) }
  431. let rj = readJson(file)
  432. if (rj.error != null) {
  433. console.log('REPORT REFUSED — ' + rj.error)
  434. return { valid = false posted = false problems = [rj.error] }
  435. }
  436. let r = rj.value
  437. let v = validate(r, reg)
  438. if (expect != null && v.problems.length == 0) {
  439. if (r.ticket != expect.ticket) { v.problems.push('ticket: "' + r.ticket + '" but this session works on ' + expect.ticket) }
  440. if (r.session != expect.session) { v.problems.push('session: "' + r.session + '" but this session is ' + expect.session) }
  441. }
  442. for (n of v.notes) { console.log('note: ' + n) }
  443. if (v.problems.length > 0) {
  444. console.log('REPORT REFUSED — ' + v.problems.length + ' problem' + (v.problems.length == 1 ? '' : 's') + ':')
  445. for (p of v.problems) { console.log(' ' + p) }
  446. return { valid = false posted = false problems = v.problems }
  447. }
  448. console.log('REPORT OK — ' + r.ticket + ', session ' + r.session + ': ' + (r.complete ? 'complete' : 'NOT complete (half done)') + ', ' + r.test.length + ' test steps, ' + r.done.length + ' done, ' + r.verified.length + ' verified, ' + r.open.length + ' open, ' + r.issues.length + ' issues, ' + r.questions.length + ' questions, ' + decidedOf(r).length + ' decided')
  449. if (!post) { return { valid = true posted = false problems = [] } }
  450. let ok = postReport(r, reg, null)
  451. return { valid = true posted = ok problems = [] }
  452. }
  453. // --post of a VALID report → true when POSTED. `note` = text for the end of the machine line ('controller pass',
  454. // missions 019/023) or null
  455. static postReport = (r, reg, note) => {
  456. // ---- --post
  457. let tk = readToken()
  458. if (tk.error != null) {
  459. console.log('POST REFUSED — ' + tk.error)
  460. return false
  461. }
  462. let token = tk.token
  463. let ref = parseRef(r.ticket)
  464. let got = getJson(ticketPath(ref.project, ref.number))
  465. if (got.status != 200) {
  466. console.log('POST REFUSED — ticket ' + r.ticket + ' → ' + got.status)
  467. return false
  468. }
  469. let ticket = got.json.ticket
  470. let ticketUrl = pageUrl(ticket)
  471. // the marker carries 12 hex of the report's sha256 (mission 023: one short line); an older comment with all
  472. // 64 starts with the same 12, so it still counts as "the same report"
  473. let hash = sha256(JSON.stringify(r)).slice(0, 12)
  474. let marker = 'colony-report: ' + r.session + ' · sha256 '
  475. let posted = false
  476. for (e of got.json.events) {
  477. if (e.kind == 'comment' && e.text != null && e.text.includes(marker)) {
  478. if (e.text.includes(marker + hash)) { posted = true } else {
  479. console.log('POST REFUSED — session ' + r.session + ' already has a DIFFERENT report on ' + r.ticket + ' (event ' + e.seq + '); one session, one report. Nothing written.')
  480. return false
  481. }
  482. }
  483. }
  484. // relations (tickets#4): the ticket's current blockers — null-safe for a tickets without relations
  485. let hasRel = ticket.keys().includes('children')
  486. let blockedKeys = []
  487. if (hasRel) { for (b of ticket.blockedBy) { blockedKeys.push(b.key) } }
  488. // issues → tickets / creator questions (idempotent by source)
  489. let filed = []
  490. let i = 0
  491. while (i < r.issues.length) {
  492. let e = r.issues[i]
  493. let src = 'colony-report:' + r.session + ':issue:' + i
  494. if (reg.projects[e.project] != null) {
  495. let res = postJson('/api/projects/' + e.project + '/tickets', { subject = e.subject summary = issueSummary(e, r.ticket, ticketUrl) source = src }, token)
  496. if (res.status == 201 || res.status == 200) {
  497. let t = res.json.ticket
  498. console.log((res.status == 201 ? ' FILED ' : ' HAVE ') + 'issue ' + i + ' → ' + t.project + t.ref + ' ' + pageUrl(t))
  499. filed.push({ link = linkOf(t) blocks = e.blocks unknown = false })
  500. // mission 022: `blocks: true` → the ticket is blocked by the issue (tickets#4; the scheduler authored the
  501. // issue, so it may link) — once: a blocker already there is not added again
  502. if (e.blocks && hasRel) {
  503. let key = t.project + '#' + t.number
  504. if (blockedKeys.includes(key)) { console.log(' HAVE ' + r.ticket + ' blocked by ' + key) } else {
  505. let bl = postJson(ticketPath(ref.project, ref.number) + '/blocked-by', { add = key }, token)
  506. if (bl.status != 201) {
  507. console.log('POST FAILED — marking ' + r.ticket + ' blocked by ' + key + ': ' + bl.status + ' ' + bl.text + ' — re-run to continue (idempotent)')
  508. return false
  509. }
  510. blockedKeys.push(key)
  511. console.log(' LINKED ' + r.ticket + ' blocked by ' + key)
  512. }
  513. }
  514. } else {
  515. console.log('POST FAILED — issue ' + i + ' (' + e.project + '): ' + res.status + ' ' + res.text + ' — re-run to continue (idempotent)')
  516. return false
  517. }
  518. } else {
  519. let uq = 'A problem found while working on [' + refLabel(r.ticket) + '](' + ticketUrl + ') belongs to "' + e.project + '", a project the colony does not know.' + NL + NL + '**Answer:** which project should get it (or "drop it")?' + NL + NL + '**Repro:** ' + e.repro.trim() + NL + NL + '**Observed:** ' + e.observed.trim() + NL + NL + '**Expected:** ' + e.expected.trim()
  520. let q = fileQuestion(token, 'colony-report:' + r.session + ':question:' + i, 'Question (' + r.ticket + '): where should "' + oneLine(e.subject, 60) + '" go?', uq, r.ticket)
  521. if (q.error != null) {
  522. console.log('POST FAILED — issue ' + i + ' (unknown project ' + e.project + '): ' + q.error + ' — re-run to continue (idempotent)')
  523. return false
  524. }
  525. let t = q.ticket
  526. console.log((q.created ? ' FILED ' : ' HAVE ') + 'issue ' + i + ' (unknown project ' + e.project + ') → creator question ' + t.project + t.ref + ' ' + pageUrl(t))
  527. filed.push({ link = linkOf(t) blocks = false unknown = true })
  528. }
  529. i = i + 1
  530. }
  531. // mission 022 "Creator questions are tickets": every `questions` entry → its own ticket in the ticket's project,
  532. // `awaiting creator`, parent = the ticket; idempotent by source `colony-report:<session>:questions:<i>`
  533. let asked = []
  534. i = 0
  535. while (i < r.questions.length) {
  536. let qt = r.questions[i]
  537. let q = fileQuestionIn(token, ref.project, 'colony-report:' + r.session + ':questions:' + i, questionSubject(r.ticket, qt), questionSummary(questionText(qt), r, ticketUrl), r.ticket)
  538. if (q.error != null) {
  539. console.log('POST FAILED — question ' + i + ': ' + q.error + ' — re-run to continue (idempotent)')
  540. return false
  541. }
  542. let t = q.ticket
  543. console.log((q.created ? ' FILED ' : ' HAVE ') + 'question ' + i + ' → ' + t.project + t.ref + ' (awaiting creator, parent ' + r.ticket + ') ' + pageUrl(t))
  544. asked.push(linkOf(t))
  545. i = i + 1
  546. }
  547. if (posted) {
  548. console.log(' HAVE the report comment on ' + r.ticket + ' (same session, same sha256) — comment and state untouched')
  549. console.log('POSTED — nothing new (report already filed)')
  550. return true
  551. }
  552. // mission 027: the deploy gate — a project with a live site: "Built — goes live with the next deploy." and the ticket
  553. // stays `in progress` (out of `next`) until `./colony deployed <project> <n>` posts the readable comment
  554. let gate = deployGate(r, reg)
  555. let c = postJson(ticketPath(ref.project, ref.number) + '/comments', { text = gate ? readyCommentOf(r, hash, note) : commentOf(r, filed, asked, hash, note) }, token)
  556. if (c.status != 201) {
  557. console.log('POST FAILED — comment on ' + r.ticket + ': ' + c.status + ' ' + c.text)
  558. return false
  559. }
  560. console.log(' POSTED the report comment on ' + r.ticket + ' ' + ticketUrl)
  561. let st = c.json.ticket.state
  562. if (gate) {
  563. if (st == 'open' || st == 'rejected') {
  564. let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = 'in progress' text = 'Built — waiting for the next deploy.' }, token)
  565. if (s.status != 201) {
  566. console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)
  567. return false
  568. }
  569. console.log(' STATE ' + r.ticket + ': ' + st + ' → in progress (built, waiting for the deploy)')
  570. } else {
  571. console.log(' STATE left as "' + st + '"')
  572. }
  573. console.log(' READY ' + r.ticket + ': built — goes live with the next deploy (list: ./colony ready; after the deploy: ./colony deployed ' + ref.project + ' ' + ref.number + ')')
  574. } else if (isHalfDone(r)) {
  575. // mission 026: half done → back to open (only from the lease), never "awaiting creator"
  576. if (st != 'in progress') {
  577. console.log(' STATE left as "' + st + '" (half done)')
  578. } else {
  579. let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = 'open' text = 'Back to open: half done — the next worker continues.' }, token)
  580. if (s.status != 201) {
  581. console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)
  582. return false
  583. }
  584. console.log(' STATE ' + r.ticket + ': ' + st + ' → open (half done — the next worker continues)')
  585. }
  586. } else if (st == 'awaiting creator' || st == 'confirmed' || st == 'rejected') {
  587. console.log(' STATE left as "' + st + '"')
  588. } else {
  589. let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = 'awaiting creator' text = 'Ready for you to test — see the last comment.' }, token)
  590. if (s.status != 201) {
  591. console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)
  592. return false
  593. }
  594. console.log(' STATE ' + r.ticket + ': ' + st + ' → awaiting creator')
  595. }
  596. console.log('POSTED')
  597. return true
  598. }

Branches

Latest commits

  • a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
  • c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
  • 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
  • 7f9660eeState of 2026-09-27, before the move to gitoriamre