gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitc613d26bc613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mrec613d26b/lib/status.hl

8.4 KB

  1. // status.hl — the project status and the daily summary, built from the tickets (ticket antcolony#19; concept README
  2. // "STATUS is generated from tickets, not written by models", docs/scheduler-agent.md §2 timer "daily digest"). Code only, no model.
  3. // ./colony status [--write] → per project: how many tickets in which state + what waits for the creator;
  4. // --write also writes status/STATUS.md (COLONY_STATUS_DIR)
  5. // ./colony digest [--post] → the daily summary: what waits for the creator. Dry run prints it; --post files it as
  6. // ONE ticket in the inbox project (source `colony:digest:<UTC day>` = once per day, also
  7. // after a restart) and writes status/STATUS.md. `run` does it once a day after
  8. // COLONY_DIGEST_HOUR (UTC, default 7; `off` = never).
  9. // What waits for the creator = tickets in `awaiting creator` (built work to check, or a question to answer). The digest
  10. // tickets themselves are left out (source `colony:digest:`). Names are the scheduler's vocabulary (tickets.hl reads the
  11. // new server names as the old ones).
  12. import { writeFile, mkDir, exists } from 'hl:fs'
  13. import { timestamp, now } from 'hl:time'
  14. import { allTickets, readToken, fileQuestionIn, inboxProject, pageUrl, setting } from './tickets.hl'
  15. import { isWholeNumber } from './claude.hl'
  16. import { isCreatorQuestion } from './relations.hl'
  17. import { NL, fromCwd, sortStrings } from './util.hl'
  18. static DIGEST_SOURCE = 'colony:digest:'
  19. static STATES = ['open', 'in progress', 'awaiting creator', 'on hold', 'rejected', 'answered', 'confirmed']
  20. static SHOWN = 8
  21. static isDigest = (t) => { return t.source != null && t.source.startsWith(DIGEST_SOURCE) }
  22. static dayOf = (ms) => { return timestamp(ms).slice(0, 10) }
  23. static hourOf = (ms) => { return toNumber(timestamp(ms).slice(11, 13)) }
  24. static statusDir = () => { return fromCwd(setting('COLONY_STATUS_DIR', 'status')) }
  25. // tickets by project (digest tickets left out) → { names: sorted project names, by: { project: { state: [rows] } } }
  26. static group = (rows) => {
  27. let by = {}
  28. let names = []
  29. for (t of rows) {
  30. if (!isDigest(t)) {
  31. if (by[t.project] == null) { by[t.project] = {} names.push(t.project) }
  32. if (by[t.project][t.state] == null) { by[t.project][t.state] = [] }
  33. by[t.project][t.state].push(t)
  34. }
  35. }
  36. return { names = sortStrings(names) by = by }
  37. }
  38. static bullet = (t) => { return '- ' + t.project + t.ref + ' — ' + t.subject + ' — ' + pageUrl(t) }
  39. // the tickets waiting for the creator, newest first is not needed: oldest first (they wait longest)
  40. static waiting = (g) => {
  41. let questions = []
  42. let built = []
  43. for (p of g.names) {
  44. let l = g.by[p]['awaiting creator']
  45. if (l != null) {
  46. for (t of l) { if (isCreatorQuestion(t)) { questions.push(t) } else { built.push(t) } }
  47. }
  48. }
  49. return { questions = questions built = built }
  50. }
  51. static listLines = (title, rows) => {
  52. let out = []
  53. if (rows.length == 0) { return out }
  54. out.push(title + ' (' + rows.length + ')')
  55. let n = 0
  56. for (t of rows) {
  57. if (n < SHOWN) { out.push(bullet(t)) }
  58. n = n + 1
  59. }
  60. if (rows.length > SHOWN) { out.push('- … and ' + (rows.length - SHOWN) + ' more') }
  61. return out
  62. }
  63. // `alpha 3, beta 1` — how many of the rows belong to which project
  64. static perProject = (rows) => {
  65. let n = {}
  66. let names = []
  67. for (t of rows) {
  68. if (n[t.project] == null) { n[t.project] = 0 names.push(t.project) }
  69. n[t.project] = n[t.project] + 1
  70. }
  71. let parts = []
  72. for (p of sortStrings(names)) { parts.push(p + ' ' + n[p]) }
  73. return parts.join(', ')
  74. }
  75. // the daily summary → { text, count } (count = how many tickets wait for the creator)
  76. static digestText = (rows, ms) => {
  77. let g = group(rows)
  78. let w = waiting(g)
  79. let count = w.questions.length + w.built.length
  80. let out = []
  81. if (count == 0) {
  82. out.push('Nothing waits for you today.')
  83. } else {
  84. out.push(count + ' ' + (count == 1 ? 'ticket waits' : 'tickets wait') + ' for you.')
  85. out.push('')
  86. for (l of listLines('Questions to answer', w.questions)) { out.push(l) }
  87. if (w.questions.length > 0 && w.built.length > 0) { out.push('') }
  88. for (l of listLines('Built, to check', w.built)) { out.push(l) }
  89. if (w.built.length > SHOWN) { out.push('By project: ' + perProject(w.built)) }
  90. }
  91. let working = 0
  92. let queued = 0
  93. for (p of g.names) {
  94. let a = g.by[p]['in progress']
  95. let o = g.by[p]['open']
  96. if (a != null) { working = working + a.length }
  97. if (o != null) { queued = queued + o.length }
  98. }
  99. out.push('')
  100. out.push('The colony is working on ' + working + ' and has ' + queued + ' more in the queue.')
  101. return { text = out.join(NL) count = count }
  102. }
  103. // the status of every project → Markdown (generated, never edited by hand)
  104. static statusText = (rows, ms) => {
  105. let g = group(rows)
  106. let out = []
  107. out.push('# Status')
  108. out.push('')
  109. out.push('Generated from the tickets at ' + timestamp(ms).slice(0, 19) + 'Z — do not edit by hand.')
  110. out.push('')
  111. out.push('| project | open | in progress | awaiting creator | on hold | rejected | confirmed |')
  112. out.push('|---|---|---|---|---|---|---|')
  113. for (p of g.names) {
  114. let cells = []
  115. for (s of ['open', 'in progress', 'awaiting creator', 'on hold', 'rejected', 'confirmed']) {
  116. let l = g.by[p][s]
  117. let n = l == null ? 0 : l.length
  118. if (s == 'awaiting creator' && g.by[p]['answered'] != null) { n = n + g.by[p]['answered'].length }
  119. cells.push('' + n)
  120. }
  121. out.push('| ' + p + ' | ' + cells.join(' | ') + ' |')
  122. }
  123. out.push('')
  124. out.push('## What waits for the creator')
  125. out.push('')
  126. out.push(digestText(rows, ms).text)
  127. for (p of g.names) {
  128. let a = g.by[p]['in progress']
  129. if (a != null) {
  130. out.push('')
  131. for (l of listLines('Being worked on in ' + p, a)) { out.push(l) }
  132. }
  133. }
  134. return out.join(NL) + NL
  135. }
  136. static writeStatusFile = (rows, ms) => {
  137. let dir = statusDir()
  138. if (!exists(dir)) { mkDir(dir, 493) }
  139. writeFile(dir + '/STATUS.md', statusText(rows, ms), 420)
  140. return dir + '/STATUS.md'
  141. }
  142. // file the summary of `day` in the inbox (idempotent by source) → { ticket, created, text, count } or { error }
  143. static postDigest = (rows, ms, token) => {
  144. let day = dayOf(ms)
  145. let d = digestText(rows, ms)
  146. let subject = 'Daily summary ' + day + (d.count == 0 ? ' — nothing waits for you' : ' — ' + d.count + (d.count == 1 ? ' ticket waits' : ' tickets wait') + ' for you')
  147. let r = fileQuestionIn(token, inboxProject(), DIGEST_SOURCE + day, subject, d.text + NL + NL + 'colony-digest: ' + day, null)
  148. if (r.error != null) { return r }
  149. return { ticket = r.ticket created = r.created text = d.text count = d.count }
  150. }
  151. // the daemon: once a day after the digest hour. `st` = { day } remembers the last day done in this process.
  152. // Returns a log line or null.
  153. static digestTick = (st, ms) => {
  154. let h = setting('COLONY_DIGEST_HOUR', '7')
  155. if (h == 'off') { return null }
  156. if (!isWholeNumber(h) || toNumber(h) > 23) { return null }
  157. let hour = toNumber(h)
  158. if (st.day == dayOf(ms) || hourOf(ms) < hour) { return null }
  159. let a = allTickets()
  160. if (a.error != null) { return 'digest: ' + a.error }
  161. let tk = readToken()
  162. if (tk.error != null) { return 'digest: ' + tk.error }
  163. let r = postDigest(a.tickets, ms, tk.token)
  164. if (r.error != null) { return 'digest: ' + r.error }
  165. writeStatusFile(a.tickets, ms)
  166. st.day = dayOf(ms)
  167. return 'DIGEST ' + dayOf(ms) + ' — ' + r.count + ' waiting · ' + (r.created ? 'filed ' : 'already filed ') + r.ticket.project + r.ticket.ref
  168. }
  169. static runStatus = (argv) => {
  170. let a = allTickets()
  171. if (a.error != null) { console.log('status: ' + a.error) return false }
  172. let ms = now()
  173. console.log(statusText(a.tickets, ms))
  174. if (argv.includes('--write')) { console.log('written: ' + writeStatusFile(a.tickets, ms)) }
  175. return true
  176. }
  177. static runDigest = (argv) => {
  178. let a = allTickets()
  179. if (a.error != null) { console.log('digest: ' + a.error) return false }
  180. let ms = now()
  181. if (!argv.includes('--post')) {
  182. console.log('Daily summary ' + dayOf(ms) + ' (dry run; --post files it in ' + inboxProject() + ')')
  183. console.log('')
  184. console.log(digestText(a.tickets, ms).text)
  185. return true
  186. }
  187. let tk = readToken()
  188. if (tk.error != null) { console.log('digest: ' + tk.error) return false }
  189. let r = postDigest(a.tickets, ms, tk.token)
  190. if (r.error != null) { console.log('digest: ' + r.error) return false }
  191. console.log(r.text)
  192. console.log('')
  193. console.log((r.created ? 'filed ' : 'already filed today: ') + r.ticket.project + r.ticket.ref + ' ' + pageUrl(r.ticket))
  194. console.log('written: ' + writeStatusFile(a.tickets, ms))
  195. return true
  196. }

Branches

Latest commits

  • 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