@goodandready/dsh-kanban 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/outbound.js CHANGED
@@ -19,22 +19,32 @@
19
19
  */
20
20
  export function planAssign(task, login) {
21
21
  if (typeof task?.issueNumber !== 'number') return []
22
- return [{
22
+ const op = {
23
23
  kind: 'assign',
24
24
  owner: task.owner,
25
25
  repo: task.repo,
26
26
  index: task.issueNumber,
27
27
  login: typeof login === 'string' ? login : '',
28
- }]
28
+ }
29
+ if (task.forge) op.provider = task.forge
30
+ else if (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com')) op.provider = 'github'
31
+ if (task.issueUrl) op.issueUrl = task.issueUrl
32
+ return [op]
29
33
  }
30
34
 
31
35
  export function planOutbound(task, column, config) {
32
36
  if (typeof task?.issueNumber !== 'number') return []
33
- // Closing issue is the only mutation sent to Gitea: it is a concrete action,
34
- // not redundant mirroring.
35
- return column === 'done'
36
- ? [{ kind: 'close', owner: task.owner, repo: task.repo, index: task.issueNumber }]
37
- : []
37
+ if (column !== 'done') return []
38
+ const op = {
39
+ kind: 'close',
40
+ owner: task.owner,
41
+ repo: task.repo,
42
+ index: task.issueNumber,
43
+ }
44
+ if (task.forge) op.provider = task.forge
45
+ else if (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com')) op.provider = 'github'
46
+ if (task.issueUrl) op.issueUrl = task.issueUrl
47
+ return [op]
38
48
  }
39
49
 
40
50
  /**
@@ -71,13 +81,19 @@ export function createOutbox({ gitea, store, logger, maxAttempts = 4 }) {
71
81
  for (const item of pending) {
72
82
  try {
73
83
  if (item.op.kind === 'close') {
74
- await gitea.closeIssue({ owner: item.op.owner, repo: item.op.repo, index: item.op.index })
84
+ const closePayload = { owner: item.op.owner, repo: item.op.repo, index: item.op.index }
85
+ if (item.op.provider) closePayload.provider = item.op.provider
86
+ if (item.op.issueUrl) closePayload.issueUrl = item.op.issueUrl
87
+ await gitea.closeIssue(closePayload)
75
88
  }
76
89
  if (item.op.kind === 'assign') {
77
- await gitea.setAssignees({
90
+ const assignPayload = {
78
91
  owner: item.op.owner, repo: item.op.repo, index: item.op.index,
79
92
  logins: item.op.login === '' ? [] : [item.op.login],
80
- })
93
+ }
94
+ if (item.op.provider) assignPayload.provider = item.op.provider
95
+ if (item.op.issueUrl) assignPayload.issueUrl = item.op.issueUrl
96
+ await gitea.setAssignees(assignPayload)
81
97
  }
82
98
  sent += 1
83
99
  } catch (error) {
@@ -62,15 +62,22 @@ export function parseProgressDump(text) {
62
62
  const line = rawLine.trim()
63
63
  const lower = line.toLowerCase()
64
64
 
65
- if (lower.startsWith('goal:') || lower.startsWith('\u0446\u0435\u043b\u044c:')) {
65
+ if (lower.startsWith('goal:') || lower.startsWith('цель:')) {
66
66
  currentKey = 'goal'
67
67
  const val = line.slice(line.indexOf(':') + 1).trim()
68
68
  if (val) sections.goal.push(val)
69
- } else if (lower.startsWith('progress:') || lower.startsWith('\u043f\u0440\u043e\u0433\u0440\u0435\u0441\u0441:')) {
69
+ } else if (lower.startsWith('progress:') || lower.startsWith('done:') || lower.startsWith('прогресс:')) {
70
70
  currentKey = 'progress'
71
71
  const val = line.slice(line.indexOf(':') + 1).trim()
72
72
  if (val) sections.progress.push(val)
73
- } else if (lower.startsWith('next:') || lower.startsWith('next steps:') || lower.startsWith('\u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0435 \u0448\u0430\u0433\u0438:') || lower.startsWith('\u0448\u0430\u0433\u0438:')) {
73
+ } else if (
74
+ lower.startsWith('next:') ||
75
+ lower.startsWith('next steps:') ||
76
+ lower.startsWith('next step:') ||
77
+ lower.startsWith('следующие шаги:') ||
78
+ lower.startsWith('шаги:') ||
79
+ lower.startsWith('дальше:')
80
+ ) {
74
81
  currentKey = 'next'
75
82
  const val = line.slice(line.indexOf(':') + 1).trim()
76
83
  if (val) sections.next.push(val)
@@ -98,16 +105,14 @@ export function parseProgressDump(text) {
98
105
  */
99
106
  export function formatProgressDump(dump) {
100
107
  if (!dump) return ''
108
+ const g = dump.goal || ''
109
+ const p = dump.progress || ''
110
+ const n = dump.next || ''
101
111
  return [
102
112
  BEGIN_MARKER,
103
- 'Goal:',
104
- dump.goal || '—',
105
- '',
106
- 'Progress:',
107
- dump.progress || '—',
108
- '',
109
- 'Next steps:',
110
- dump.next || '—',
113
+ `Goal: ${g}`,
114
+ `Progress: ${p}`,
115
+ `Next steps: ${n}`,
111
116
  END_MARKER,
112
117
  ].join('\n')
113
118
  }
package/lib/routes.js CHANGED
@@ -235,15 +235,30 @@ export function updateTask({ store, id, input }) {
235
235
  patch.permission = input.permission
236
236
  if (input.permission !== current.permission) {
237
237
  patch.permissionConfirmedAt = 0
238
+ patch.permissionConfirmedTier = ''
238
239
  }
239
240
  }
241
+ if (typeof input?.permissionConfirmedTier === 'string') {
242
+ patch.permissionConfirmedTier = input.permissionConfirmedTier
243
+ }
244
+ if (typeof input?.reasoningEffort === 'string') {
245
+ const val = input.reasoningEffort.trim().toLowerCase()
246
+ if (['', 'low', 'medium', 'high', 'default', 'none', 'minimal', 'thorough'].includes(val)) {
247
+ patch.reasoningEffort = val
248
+ }
249
+ }
250
+ if (typeof input?.forge === 'string') {
251
+ const val = input.forge.trim().toLowerCase()
252
+ if (val === 'github' || val === 'gitea') patch.forge = val
253
+ }
240
254
  return { task: store.updateTask(id, patch) }
241
255
  }
242
256
 
243
- export function confirmPermission({ store, id }) {
257
+ export function confirmPermission({ store, id, tier }) {
244
258
  const task = store.getTask(id)
245
259
  if (task === undefined) return { error: 'task-not-found', status: 404 }
246
- return { task: store.updateTask(id, { permissionConfirmedAt: Date.now() }) }
260
+ const effectiveTier = typeof tier === 'string' && tier ? tier : (task.permission || 'default')
261
+ return { task: store.updateTask(id, { permissionConfirmedAt: Date.now(), permissionConfirmedTier: effectiveTier }) }
247
262
  }
248
263
 
249
264
  /** Known priority values. Empty string indicates unprioritized. */
@@ -405,27 +420,22 @@ export function importBoard({ store, input }) {
405
420
  for (const task of input.tasks) {
406
421
  if (typeof task?.id !== 'string' || task.id === '') continue
407
422
  if (store.getTask(task.id) !== undefined) { skipped += 1; continue }
408
- store.createTask({
409
- id: task.id,
410
- board: task.board ?? 'main',
411
- column: task.column ?? 'backlog',
412
- title: task.title ?? '',
413
- body: task.body ?? '',
414
- owner: task.owner, repo: task.repo,
415
- issueNumber: task.issueNumber, issueUrl: task.issueUrl,
416
- labels: Array.isArray(task.labels) ? task.labels : [],
417
- branch: task.branch, worktree: task.worktree,
418
- sessionId: task.sessionId, model: task.model, provider: task.provider,
419
- waiting: task.waiting === true,
420
- plan: task.plan ?? '',
421
- labelColors: task.labelColors ?? {},
422
- author: task.author ?? '',
423
- priority: task.priority ?? '',
424
- dueAt: typeof task.dueAt === 'number' ? task.dueAt : 0,
425
- createdAt: task.createdAt,
426
- })
423
+ if (typeof store.restoreTask === 'function') {
424
+ store.restoreTask(task)
425
+ } else {
426
+ store.createTask(task)
427
+ }
427
428
  imported += 1
428
429
  }
430
+
431
+ for (const trans of Array.isArray(input.transitions) ? input.transitions : []) {
432
+ if (typeof trans?.taskId !== 'string' || trans.taskId === '') continue
433
+ if (typeof store.restoreTransition === 'function') {
434
+ store.restoreTransition(trans)
435
+ } else if (typeof store.addTransition === 'function') {
436
+ store.addTransition(trans)
437
+ }
438
+ }
429
439
  }
430
440
 
431
441
  if (typeof store.withTransaction === 'function') {
@@ -464,7 +474,12 @@ export function exportTasksCsv({ store, board = 'main' }) {
464
474
 
465
475
  function escapeCsv(val) {
466
476
  if (val === null || val === undefined) return ''
467
- const str = String(val)
477
+ let str = String(val)
478
+ // OWASP CSV Injection mitigation (#340)
479
+ const trimmed = str.replace(/^[\s\t\r\n]+/, '')
480
+ if (/^[=+\-@\t\r]/.test(str) || (trimmed.length > 0 && /^[=+\-@]/.test(trimmed))) {
481
+ str = `'${str}`
482
+ }
468
483
  if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
469
484
  return `"${str.replace(/"/g, '""')}"`
470
485
  }
@@ -809,10 +824,11 @@ export function handleToggleChecklistItem({ store, task, input }) {
809
824
  let updatedItem
810
825
  if (typeof item === 'string') {
811
826
  const checked = input?.checked !== undefined ? Boolean(input.checked) : true
812
- updatedItem = { text: item, done: checked }
827
+ updatedItem = { text: item, completed: checked, done: checked }
813
828
  } else {
814
- const checked = input?.checked !== undefined ? Boolean(input.checked) : !item.done
815
- updatedItem = { ...item, done: checked }
829
+ const isCurrentlyDone = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
830
+ const checked = input?.checked !== undefined ? Boolean(input.checked) : !isCurrentlyDone
831
+ updatedItem = { ...item, completed: checked, done: checked }
816
832
  }
817
833
  current[targetIndex] = updatedItem
818
834
  const updated = store.updateChecklist(task.id, current)
package/lib/store.js CHANGED
@@ -13,7 +13,7 @@ import { DatabaseSync } from 'node:sqlite'
13
13
  import { randomUUID } from 'node:crypto'
14
14
  import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync, chmodSync, existsSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
- import { keyBetween } from './order.js'
16
+ import { keyBetween, generateNKeysBetween } from './order.js'
17
17
  import { DEFAULT_BOARDS, normalizeKind } from './config.js'
18
18
 
19
19
  const SCHEMA = `
@@ -103,7 +103,7 @@ const WRITABLE = [
103
103
  'labelColors', 'queuedAt', 'createdAt', 'author', 'assignee', 'milestone', 'priority', 'dueAt',
104
104
  'cron', 'nextRunAt', 'lastRunAt', 'progressDump', 'permission', 'reasoningEffort', 'permissionConfirmedAt',
105
105
  'runs', 'checklist', 'report', 'comments', 'rejectReason', 'rejectedAt', 'parentId', 'isEpic',
106
- 'workflowRunId', 'workflowStatus',
106
+ 'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge',
107
107
  ]
108
108
 
109
109
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
@@ -242,6 +242,8 @@ export function openStore({ dir }) {
242
242
  ['provider', "TEXT NOT NULL DEFAULT 'gitea'"],
243
243
  ['workflowRunId', "TEXT NOT NULL DEFAULT ''"],
244
244
  ['workflowStatus', "TEXT NOT NULL DEFAULT ''"],
245
+ ['forge', "TEXT NOT NULL DEFAULT ''"],
246
+ ['permissionConfirmedTier', "TEXT NOT NULL DEFAULT ''"],
245
247
  ]) {
246
248
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
247
249
  }
@@ -258,6 +260,35 @@ export function openStore({ dir }) {
258
260
  db.exec('CREATE INDEX IF NOT EXISTS tasks_parent_id ON tasks(parentId)')
259
261
  db.exec('CREATE INDEX IF NOT EXISTS tasks_updated ON tasks(updatedAt)')
260
262
  db.exec('CREATE INDEX IF NOT EXISTS tasks_archivable ON tasks(archivedAt, col, columnAt)')
263
+ try { db.exec("ALTER TABLE dismissed ADD COLUMN forge TEXT NOT NULL DEFAULT ''") } catch { /* already exists */ }
264
+ db.exec("CREATE INDEX IF NOT EXISTS tasks_forge_issue ON tasks(forge, owner, repo, issueNumber)")
265
+ db.exec("UPDATE tasks SET forge = 'github' WHERE (forge = '' OR forge IS NULL) AND issueUrl LIKE '%github.com%'")
266
+ db.exec("UPDATE tasks SET forge = 'gitea' WHERE (forge = '' OR forge IS NULL) AND (issueUrl NOT LIKE '%github.com%' OR issueUrl IS NULL)")
267
+ db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, val TEXT)")
268
+
269
+ // Fractional indexing migration (#345): ensure all existing tasks have compact keys
270
+ const orderMigrated = db.prepare("SELECT val FROM meta WHERE key = 'fractional_indexing_migrated'").get()
271
+ if (!orderMigrated) {
272
+ try {
273
+ db.exec('BEGIN TRANSACTION')
274
+ const groups = db.prepare('SELECT DISTINCT board, col FROM tasks').all()
275
+ const getTasksInCol = db.prepare('SELECT id, position FROM tasks WHERE board = ? AND col = ? ORDER BY position ASC, createdAt ASC')
276
+ const updatePos = db.prepare('UPDATE tasks SET position = ? WHERE id = ?')
277
+ for (const group of groups) {
278
+ const rows = getTasksInCol.all(group.board, group.col)
279
+ if (rows.length > 0) {
280
+ const newKeys = generateNKeysBetween(null, null, rows.length)
281
+ for (let i = 0; i < rows.length; i++) {
282
+ updatePos.run(newKeys[i], rows[i].id)
283
+ }
284
+ }
285
+ }
286
+ db.prepare("INSERT OR REPLACE INTO meta (key, val) VALUES ('fractional_indexing_migrated', '1')").run()
287
+ db.exec('COMMIT')
288
+ } catch (e) {
289
+ try { db.exec('ROLLBACK') } catch { /* ignore */ }
290
+ }
291
+ }
261
292
  db.exec('PRAGMA synchronous = NORMAL')
262
293
  try { db.exec('PRAGMA optimize') } catch { /* pragma optimize best-effort */ }
263
294
 
@@ -274,6 +305,35 @@ export function openStore({ dir }) {
274
305
  checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
275
306
  workflowRunId, workflowStatus)
276
307
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
308
+ restoreTask: db.prepare(`INSERT INTO tasks
309
+ (id, board, col, position, title, body, owner, repo, issueNumber, issueUrl, externalRef,
310
+ labels, branch, worktree, workspaceId, sessionId, model, provider, waiting, syncedAt,
311
+ labelColors, author, assignee, milestone, priority, dueAt, columnAt, createdAt, updatedAt,
312
+ cron, nextRunAt, lastRunAt, progressDump, permission, permissionConfirmedAt, permissionConfirmedTier, runs,
313
+ checklist, report, comments, rejectReason, rejectedAt, reasoningEffort, parentId, isEpic,
314
+ workflowRunId, workflowStatus, archivedAt, queuedAt, plan, forge)
315
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
316
+ ON CONFLICT(id) DO UPDATE SET
317
+ board=excluded.board, col=excluded.col, position=excluded.position, title=excluded.title,
318
+ body=excluded.body, owner=excluded.owner, repo=excluded.repo, issueNumber=excluded.issueNumber,
319
+ issueUrl=excluded.issueUrl, externalRef=excluded.externalRef, labels=excluded.labels,
320
+ branch=excluded.branch, worktree=excluded.worktree, workspaceId=excluded.workspaceId,
321
+ sessionId=excluded.sessionId, model=excluded.model, provider=excluded.provider,
322
+ waiting=excluded.waiting, syncedAt=excluded.syncedAt, labelColors=excluded.labelColors,
323
+ author=excluded.author, assignee=excluded.assignee, milestone=excluded.milestone,
324
+ priority=excluded.priority, dueAt=excluded.dueAt, columnAt=excluded.columnAt,
325
+ createdAt=excluded.createdAt, updatedAt=excluded.updatedAt, cron=excluded.cron,
326
+ nextRunAt=excluded.nextRunAt, lastRunAt=excluded.lastRunAt, progressDump=excluded.progressDump,
327
+ permission=excluded.permission, permissionConfirmedAt=excluded.permissionConfirmedAt,
328
+ permissionConfirmedTier=excluded.permissionConfirmedTier, runs=excluded.runs,
329
+ checklist=excluded.checklist, report=excluded.report, comments=excluded.comments,
330
+ rejectReason=excluded.rejectReason, rejectedAt=excluded.rejectedAt,
331
+ reasoningEffort=excluded.reasoningEffort, parentId=excluded.parentId, isEpic=excluded.isEpic,
332
+ workflowRunId=excluded.workflowRunId, workflowStatus=excluded.workflowStatus,
333
+ archivedAt=excluded.archivedAt, queuedAt=excluded.queuedAt, plan=excluded.plan, forge=excluded.forge`),
334
+ restoreTransition: db.prepare(`INSERT INTO transitions
335
+ (taskId, fromCol, toCol, source, detail, at)
336
+ VALUES (?, ?, ?, ?, ?, ?)`),
277
337
  boardById: db.prepare('SELECT * FROM boards WHERE id = ?'),
278
338
  insertBoard: db.prepare('INSERT INTO boards (id, title, kind, position, createdAt) VALUES (?, ?, ?, ?, ?)'),
279
339
  lastBoard: db.prepare('SELECT position FROM boards ORDER BY position DESC LIMIT 1'),
@@ -286,7 +346,9 @@ export function openStore({ dir }) {
286
346
  "SELECT * FROM tasks WHERE archivedAt = 0 AND col = 'done' AND columnAt > 0 AND columnAt <= ?"),
287
347
  dismiss: db.prepare('INSERT OR REPLACE INTO dismissed (owner, repo, issueNumber, at) VALUES (?, ?, ?, ?)'),
288
348
  isDismissed: db.prepare('SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ?'),
349
+ isDismissedWithForge: db.prepare(`SELECT 1 FROM dismissed WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
289
350
  byIssue: db.prepare('SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ?'),
351
+ byIssueWithForge: db.prepare(`SELECT * FROM tasks WHERE owner = ? AND repo = ? AND issueNumber = ? AND (forge = ? OR (forge = '' AND ? = 'gitea'))`),
290
352
  byExternalRef: db.prepare('SELECT * FROM tasks WHERE externalRef = ? ORDER BY createdAt ASC LIMIT 1'),
291
353
  tasksBySession: db.prepare('SELECT * FROM tasks WHERE sessionId = ? LIMIT 1'),
292
354
  lastInColumn: db.prepare('SELECT position FROM tasks WHERE board = ? AND col = ? ORDER BY position DESC LIMIT 1'),
@@ -553,10 +615,91 @@ export function openStore({ dir }) {
553
615
  updateChecklist(id, items) {
554
616
  const task = rowToTask(q.taskById.get(id))
555
617
  if (!task) return undefined
556
- const list = Array.isArray(items) ? items : []
618
+ const list = (Array.isArray(items) ? items : []).map((item) => {
619
+ if (typeof item === 'string') return { text: item, completed: false, done: false }
620
+ const c = item.completed !== undefined ? Boolean(item.completed) : Boolean(item.done)
621
+ return { ...item, completed: c, done: c }
622
+ })
557
623
  return this.updateTask(id, { checklist: list })
558
624
  },
559
625
 
626
+ restoreTask(task) {
627
+ if (!task || !task.id) return undefined
628
+ const labels = JSON.stringify(Array.isArray(task.labels) ? task.labels : [])
629
+ const colors = JSON.stringify(task.labelColors && typeof task.labelColors === 'object' ? task.labelColors : {})
630
+ const runs = JSON.stringify(Array.isArray(task.runs) ? task.runs : [])
631
+ const checklist = JSON.stringify(Array.isArray(task.checklist) ? task.checklist : [])
632
+ const report = JSON.stringify(task.report && typeof task.report === 'object' ? task.report : {})
633
+ const comments = JSON.stringify(Array.isArray(task.comments) ? task.comments : [])
634
+ const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
635
+ q.restoreTask.run(
636
+ task.id,
637
+ task.board || 'main',
638
+ task.column || task.col || 'backlog',
639
+ task.position || keyBetween(undefined, undefined),
640
+ task.title || '',
641
+ task.body || '',
642
+ task.owner ?? null,
643
+ task.repo ?? null,
644
+ task.issueNumber ?? null,
645
+ task.issueUrl ?? null,
646
+ task.externalRef ?? null,
647
+ labels,
648
+ task.branch ?? null,
649
+ task.worktree ?? null,
650
+ task.workspaceId || '',
651
+ task.sessionId ?? null,
652
+ task.model ?? null,
653
+ task.provider ?? 'gitea',
654
+ task.waiting ? 1 : 0,
655
+ task.syncedAt || 0,
656
+ colors,
657
+ task.author || '',
658
+ task.assignee || '',
659
+ task.milestone || '',
660
+ task.priority || '',
661
+ task.dueAt || 0,
662
+ task.columnAt || task.createdAt || Date.now(),
663
+ task.createdAt || Date.now(),
664
+ task.updatedAt || Date.now(),
665
+ task.cron || '',
666
+ task.nextRunAt || 0,
667
+ task.lastRunAt || 0,
668
+ task.progressDump || '',
669
+ task.permission || '',
670
+ task.permissionConfirmedAt || 0,
671
+ task.permissionConfirmedTier || '',
672
+ runs,
673
+ checklist,
674
+ report,
675
+ comments,
676
+ task.rejectReason || '',
677
+ task.rejectedAt || 0,
678
+ task.reasoningEffort || '',
679
+ task.parentId || '',
680
+ task.isEpic ? 1 : 0,
681
+ task.workflowRunId || '',
682
+ task.workflowStatus || '',
683
+ task.archivedAt || 0,
684
+ task.queuedAt || 0,
685
+ task.plan || '',
686
+ forge,
687
+ )
688
+ return rowToTask(q.taskById.get(task.id))
689
+ },
690
+
691
+ restoreTransition(trans) {
692
+ if (!trans || !trans.taskId) return undefined
693
+ q.restoreTransition.run(
694
+ trans.taskId,
695
+ trans.fromCol ?? null,
696
+ trans.toCol || '',
697
+ trans.source || 'unknown',
698
+ trans.detail || '',
699
+ trans.at || Date.now(),
700
+ )
701
+ },
702
+
560
703
  setReport(id, reportData) {
561
704
  const task = rowToTask(q.taskById.get(id))
562
705
  if (!task) return undefined
@@ -621,12 +764,18 @@ export function openStore({ dir }) {
621
764
  },
622
765
 
623
766
  /** Whether an issue was previously dismissed. */
624
- isDismissed({ owner, repo, issueNumber }) {
767
+ isDismissed({ owner, repo, issueNumber, forge }) {
768
+ if (forge) {
769
+ return q.isDismissedWithForge.get(owner, repo, issueNumber, forge, forge) !== undefined
770
+ }
625
771
  return q.isDismissed.get(owner, repo, issueNumber) !== undefined
626
772
  },
627
773
 
628
774
  /** Find task by issue to prevent duplicate import. */
629
- findTaskByIssue({ owner, repo, issueNumber }) {
775
+ findTaskByIssue({ owner, repo, issueNumber, forge }) {
776
+ if (forge) {
777
+ return rowToTask(q.byIssueWithForge.get(owner, repo, issueNumber, forge, forge))
778
+ }
630
779
  return rowToTask(q.byIssue.get(owner, repo, issueNumber))
631
780
  },
632
781
 
@@ -667,8 +816,9 @@ export function openStore({ dir }) {
667
816
  },
668
817
 
669
818
  setArchived(id, archivedAt) {
819
+ const at = typeof archivedAt === 'boolean' ? (archivedAt ? Date.now() : 0) : (Number(archivedAt) || 0)
670
820
  db.prepare('UPDATE tasks SET archivedAt = ?, updatedAt = ? WHERE id = ?')
671
- .run(archivedAt, Date.now(), id)
821
+ .run(at, Date.now(), id)
672
822
  return rowToTask(q.taskById.get(id))
673
823
  },
674
824
 
package/lib/sync.js CHANGED
@@ -39,7 +39,7 @@ export async function withBackoff(fn, { retries = 2, baseMs = 50, maxMs = 500 }
39
39
  * Failures are not silently swallowed: incomplete observations (missing PRs or branches)
40
40
  * would lead to erroneous column transitions. If reading fails, the repository is skipped.
41
41
  */
42
- async function readRepo({ gitea, owner, repo }) {
42
+ async function readRepo({ gitea, owner, repo, forge }) {
43
43
  const [pulls, branches] = await Promise.all([
44
44
  gitea.listPulls({ owner, repo, state: 'all' }),
45
45
  gitea.listBranches({ owner, repo }),
@@ -182,8 +182,9 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
182
182
  const tasks = store.listWatchable().filter((t) => (only ? only(t) : true))
183
183
  const byRepo = new Map()
184
184
  for (const task of tasks) {
185
- const key = `${task.owner}/${task.repo}`
186
- if (!byRepo.has(key)) byRepo.set(key, { owner: task.owner, repo: task.repo, tasks: [] })
185
+ const forge = task.forge || (typeof task.issueUrl === 'string' && task.issueUrl.includes('github.com') ? 'github' : 'gitea')
186
+ const key = `${forge}:${task.owner}/${task.repo}`
187
+ if (!byRepo.has(key)) byRepo.set(key, { forge, owner: task.owner, repo: task.repo, tasks: [] })
187
188
  byRepo.get(key).tasks.push(task)
188
189
  }
189
190
 
@@ -200,7 +201,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
200
201
  for (const group of byRepo.values()) {
201
202
  let repoState
202
203
  try {
203
- repoState = await withBackoff(() => readRepo({ gitea, owner: group.owner, repo: group.repo }), { retries: 2, baseMs: 40 })
204
+ repoState = await withBackoff(() => readRepo({ gitea, owner: group.owner, repo: group.repo, forge: group.forge }), { retries: 2, baseMs: 40 })
204
205
  } catch (error) {
205
206
  failed += group.tasks.length
206
207
  reposFailed += 1
@@ -212,7 +213,7 @@ export async function syncAll({ gitea, store, logger, only, now = Date.now() })
212
213
  for (const task of group.tasks) {
213
214
  checked += 1
214
215
  try {
215
- const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber })
216
+ const issue = await gitea.getIssue({ owner: task.owner, repo: task.repo, index: task.issueNumber, provider: group.forge, issueUrl: task.issueUrl })
216
217
  const observation = observeTask(task, repoState, issue)
217
218
  const applied = applyObservation({
218
219
  store, task, observation, issue, remoteUpdatedAt: issue?.updated_at,
package/lib/updater.js CHANGED
@@ -47,7 +47,7 @@ export function isTrustedUpdateRequest(request) {
47
47
  const remote = request.socket?.remoteAddress
48
48
  if (!isLoopback(remote) && !isPrivateLan(remote)) return false
49
49
  const site = header(request, 'sec-fetch-site')
50
- if (site !== undefined && site !== 'same-origin') return false
50
+ if (!site || (site !== 'same-origin' && site !== 'same-site' && site !== 'none')) return false
51
51
  const origin = header(request, 'origin')
52
52
  const host = header(request, 'host')
53
53
  if (origin === undefined || host === undefined) return false
@@ -11,7 +11,14 @@
11
11
  // - post summary to linked Gitea / GitHub issue.
12
12
  // 5. Cancel active workflow on card request.
13
13
 
14
- import { buildStartMessage, obtainAgent } from './launcher.js'
14
+ import {
15
+ buildStartMessage,
16
+ obtainAgent,
17
+ verifyPermissionGate,
18
+ countActiveSessions,
19
+ reserveSessionSlot,
20
+ releaseSessionSlot,
21
+ } from './launcher.js'
15
22
  import { applyMove } from './routes.js'
16
23
  import { defaultGitRunner } from './worktree.js'
17
24
 
@@ -169,11 +176,11 @@ export function buildWorkflowMeta({ task, role }) {
169
176
  */
170
177
  export function resolveTargetColumn(currentCol) {
171
178
  const col = String(currentCol || '').toLowerCase()
172
- if (col === 'backlog' || col === 'ready' || col === 'progress') {
179
+ if (col === 'backlog' || col === 'ready' || col === 'progress' || col === 'in-progress') {
173
180
  return 'review'
174
181
  }
175
182
  if (col === 'review') {
176
- return 'done'
183
+ return 'review'
177
184
  }
178
185
  return col || 'review'
179
186
  }
@@ -268,21 +275,40 @@ export class WorkflowBridge {
268
275
  })
269
276
 
270
277
  const config = this.getConfig()
271
- const taken = await obtainAgent({
272
- agents: this.agents,
273
- task,
274
- config,
275
- provider,
276
- model,
277
- mintSessionId,
278
- cwdOf,
279
- logger: this.logger,
280
- agentPreset: preset,
281
- permission,
282
- permissions,
283
- gitRunner,
284
- workspaceRegistry,
285
- })
278
+ verifyPermissionGate({ task, permission, config })
279
+
280
+ const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
281
+ if (maxConcurrent > 0 && this.agents && this.store) {
282
+ const activeCount = countActiveSessions({ store: this.store, agents: this.agents })
283
+ if (activeCount >= maxConcurrent) {
284
+ const err = new Error('concurrent-limit-reached')
285
+ err.key = 'concurrent-limit-reached'
286
+ err.status = 429
287
+ throw err
288
+ }
289
+ }
290
+
291
+ reserveSessionSlot(task.id)
292
+ let taken
293
+ try {
294
+ taken = await obtainAgent({
295
+ agents: this.agents,
296
+ task,
297
+ config,
298
+ provider,
299
+ model,
300
+ mintSessionId,
301
+ cwdOf,
302
+ logger: this.logger,
303
+ agentPreset: preset,
304
+ permission,
305
+ permissions,
306
+ gitRunner,
307
+ workspaceRegistry,
308
+ })
309
+ } finally {
310
+ releaseSessionSlot(task.id)
311
+ }
286
312
 
287
313
  const promptText = typeof text === 'string' && text.trim() !== ''
288
314
  ? text.trim()