@ljwei-stak/dsh-model-route-bot 0.64.0 → 0.65.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 (62) hide show
  1. package/.dsh-plugin/client.js +694 -407
  2. package/README.i18n.yaml +2 -2
  3. package/README.md +209 -147
  4. package/README.zh.md +208 -146
  5. package/apps/integration-client/bot-attention.mjs +2 -2
  6. package/apps/integration-client/panels.jsx +239 -134
  7. package/apps/integration-client/product.css +36 -0
  8. package/apps/integration-client/routing-submissions.mjs +68 -0
  9. package/apps/integration-client/task-progress.mjs +46 -0
  10. package/apps/integration-client/tasks-panel.jsx +147 -0
  11. package/modules/bot-runtime/read-model.mjs +2 -2
  12. package/package.json +6 -87
  13. package/packages/model-router-plugin/README.md +39 -15
  14. package/packages/model-router-plugin/README.zh.md +40 -16
  15. package/packages/model-router-plugin/package.json +1 -1
  16. package/packages/orchestration/src/bot-routing-candidates.mjs +6 -6
  17. package/packages/orchestration/src/bot-routing-service.mjs +30 -5
  18. package/packages/orchestration/src/bot-routing.mjs +6 -4
  19. package/packages/orchestration/src/product-composition.mjs +18 -24
  20. package/packages/orchestration/src/task-journal.mjs +23 -0
  21. package/packages/orchestration/src/task-product.mjs +49 -0
  22. package/remote-computer-agent/build.mjs +48 -13
  23. package/CHANGELOG.md +0 -456
  24. package/docs/INSTALLATION_GUIDE.zh.md +0 -150
  25. package/docs/MANUAL_TASK_ASSIGNMENT.zh.md +0 -46
  26. package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +0 -153
  27. package/docs/ROUTING_ALGORITHM_OPTIMIZATION.zh.md +0 -91
  28. package/docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md +0 -684
  29. package/docs/ROUTING_LIVEBENCH_DATA_SEARCH.zh.md +0 -74
  30. package/docs/ROUTING_LIVEBENCH_VALIDATION.zh.md +0 -154
  31. package/docs/ROUTING_MATURITY.zh.md +0 -76
  32. package/docs/ROUTING_RESEARCH.zh.md +0 -59
  33. package/docs/ROUTING_SELFTEST_STEPS.zh.md +0 -192
  34. package/docs/WORKBENCH_USER_GUIDE.zh.md +0 -293
  35. package/docs/archive/MIGRATION.md +0 -53
  36. package/docs/archive/README.md +0 -21
  37. package/docs/assets/candidate-pruning.svg +0 -80
  38. package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
  39. package/docs/assets/router-only-0.12.0.png +0 -0
  40. package/docs/assets/routing-workflow.svg +0 -96
  41. package/docs/assets/workbench-budget-reservations.png +0 -0
  42. package/docs/assets/workbench-run-progress.png +0 -0
  43. package/docs/assets/workbench-run-timeout.png +0 -0
  44. package/docs/assets/workbench-subscription-usage.png +0 -0
  45. package/docs/assets/workbench-task-preview.jpg +0 -0
  46. package/docs/assets/workbench-usage.svg +0 -119
  47. package/docs/releases/0.20.0-validation.md +0 -26
  48. package/docs/releases/0.22.0-validation.md +0 -28
  49. package/docs/releases/0.23.0-validation.md +0 -30
  50. package/docs/releases/0.23.1-validation.md +0 -23
  51. package/docs/releases/0.24.0-validation.md +0 -41
  52. package/docs/releases/0.25.0-validation.md +0 -41
  53. package/docs/releases/0.25.1-validation.md +0 -36
  54. package/docs/releases/0.25.2-validation.md +0 -48
  55. package/docs/releases/0.25.3-validation.md +0 -50
  56. package/docs/releases/0.25.4-validation.md +0 -41
  57. package/docs/releases/0.25.5-validation.md +0 -40
  58. package/docs/releases/0.26.0-preview.0-validation.md +0 -23
  59. package/docs/releases/0.26.0-preview.1-f-difference.json +0 -6845
  60. package/docs/releases/0.26.0-preview.1-f-difference.md +0 -85
  61. package/docs/releases/0.26.0-preview.1-validation.md +0 -103
  62. package/docs/releases/0.63.0-validation.md +0 -51
@@ -9,13 +9,13 @@
9
9
  * machine: `candidateMachineGrant` is the only way a caller may read one, and an unnamed machine
10
10
  * resolves to the Bot's own first grant rather than to a guess.
11
11
  *
12
- * Everything is injected — bots, their saved settings, machine health — so the same function runs
13
- * in the Host and in a test without a database or a live machine.
12
+ * Quality is a fraction between 0 and 1. Everything is injected — bots, their saved settings,
13
+ * machine health — so the same function runs in the Host and in a test without a database or a live machine.
14
14
  */
15
15
 
16
16
  import { workerBinding } from './bot-routing-mode.mjs'
17
17
 
18
- const finite = value => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null)
18
+ const finite = (value, max = Infinity) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max ? value : null)
19
19
  const list = value => (Array.isArray(value) ? value : [])
20
20
 
21
21
  /** Tools a Bot may actually use, from its compiled grant rather than from its own claims. */
@@ -96,7 +96,7 @@ export function candidateMachineGrant(candidate, machineId = '', rootId = '') {
96
96
  * settingsFor: (botId: string) => { enabled?: boolean, model?: object, grant?: { tools?: string[] }, capabilities?: string[], machineGrants?: Array<object> } | null,
97
97
  * machineHealth?: (machineId: string) => { online?: boolean } | null,
98
98
  * qualityFor?: (botId: string) => number | null,
99
- * priceFor?: (route: { provider: string, model: string }) => number | null,
99
+ * priceFor?: (route: { provider: string, model: string }, botId: string) => number | null,
100
100
  * loadFor?: (botId: string) => number | null,
101
101
  * busyFor?: (botId: string) => boolean,
102
102
  * }} source
@@ -110,7 +110,7 @@ export function botRoutingCandidates({ bots = [], settingsFor, machineHealth = (
110
110
  // A Bot that is off is not a candidate; it stays out rather than scoring last.
111
111
  if (!settings || settings.enabled === false || settings.status && settings.status !== 'available') continue
112
112
  const model = settings.model ?? bot.model ?? null
113
- const price = model ? priceFor(model) : null
113
+ const price = model ? priceFor(model, bot.id) : null
114
114
  // Machine grants name the machine and the tools that machine offers this Bot. Reachability is
115
115
  // asked per machine, so a Bot with two grants reports the one the task is about.
116
116
  const machineGrants = machineGrantsOf(settings)
@@ -125,7 +125,7 @@ export function botRoutingCandidates({ bots = [], settingsFor, machineHealth = (
125
125
  // Unknown price and unknown quality stay null: the router treats them as unknown, not as
126
126
  // zero or best, and never as "free".
127
127
  pricePerMTokUsd: finite(price),
128
- quality: finite(qualityFor(bot.id)),
128
+ quality: finite(qualityFor(bot.id), 1),
129
129
  load: finite(loadFor(bot.id)) ?? 0,
130
130
  busy: busyFor(bot.id) === true,
131
131
  tools: toolsOf(settings),
@@ -17,16 +17,18 @@
17
17
  import { botRoutingCandidates, candidateMachineGrant, withMachineFact } from './bot-routing-candidates.mjs'
18
18
  import { routeBot } from './bot-routing.mjs'
19
19
  import { normalizeBotRouting, workerBinding } from './bot-routing-mode.mjs'
20
- import { dispatchRoutedTask, pinnedMachineRefusal, workerGrantFact, routedEffectClass } from './bot-routing-dispatch.mjs'
20
+ import { assertPlanAccepted, dispatchRoutedTask, pinnedMachineRefusal, workerGrantFact, routedEffectClass } from './bot-routing-dispatch.mjs'
21
+ import { canonicalTaskJson, taskRef } from './task-contract.mjs'
21
22
  import { createHash } from 'node:crypto'
22
23
  const fail = code => { const error = new Error(code); error.code = code; throw error }
24
+ const submittedPlanId = (originBotId, requestKey) => `route_${createHash('sha256').update(`${originBotId}:${requestKey}`).digest('hex').slice(0, 48)}`
23
25
 
24
26
  /**
25
27
  * @param {{
26
28
  * bots: () => Array<object>,
27
29
  * settingsFor: (botId: string) => object | null,
28
30
  * machineHealth?: (machineId: string) => { online?: boolean } | null,
29
- * priceFor?: (route: object) => number | null,
31
+ * priceFor?: (route: object, botId: string) => number | null,
30
32
  * qualityFor?: (botId: string) => number | null,
31
33
  * loadFor?: (botId: string) => number | null,
32
34
  * busyFor?: (botId: string) => boolean,
@@ -36,9 +38,10 @@ const fail = code => { const error = new Error(code); error.code = code; throw e
36
38
  * subjectFor?: (botId: string) => unknown,
37
39
  * bindWorker?: (binding: object) => Promise<unknown> | unknown,
38
40
  * newTaskId?: () => string,
41
+ * readSubmittedPlan?: (originBotId: string, planId: string) => object | Promise<object>,
39
42
  * }} source
40
43
  */
41
- export function createBotRoutingService({ bots, settingsFor, machineHealth, priceFor, qualityFor, loadFor, busyFor, routing, mainBotIds = [], submitPlan = null, subjectFor = null, bindWorker = null, newTaskId = null, executionBackendFor = null, settle = null, deliver = null, onAccepted = null }) {
44
+ export function createBotRoutingService({ bots, settingsFor, machineHealth, priceFor, qualityFor, loadFor, busyFor, routing, mainBotIds = [], submitPlan = null, subjectFor = null, bindWorker = null, newTaskId = null, readSubmittedPlan = null, executionBackendFor = null, settle = null, deliver = null, onAccepted = null }) {
42
45
  if (typeof bots !== 'function') fail('BOT_ROUTING_REQUIRES_BOTS')
43
46
  if (typeof settingsFor !== 'function') fail('BOT_ROUTING_REQUIRES_SETTINGS')
44
47
 
@@ -81,7 +84,7 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
81
84
  quality: candidate.quality,
82
85
  busy: candidate.busy,
83
86
  }))
84
- return { enabled: settings.enabled, preset: settings.preset, autoGrant: settings.autoGrant, candidates: rows }
87
+ return { enabled: settings.enabled, preset: settings.preset, autoGrant: settings.autoGrant, requesterBotId: readMainBotIds()[0] ?? null, candidates: rows }
85
88
  },
86
89
 
87
90
  /**
@@ -132,6 +135,27 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
132
135
  }
133
136
  },
134
137
 
138
+ /** Owner-only observation: no candidate reads, grant changes, runner or routing switch needed. */
139
+ async submissionStatus(payload, context = {}) {
140
+ if (context?.kind !== 'owner') fail('REMOTE_OWNER_REQUIRED')
141
+ const value = JSON.parse(canonicalTaskJson(payload))
142
+ if (!value || Array.isArray(value) || Object.keys(value).some(key => !['requestKey', 'requesterBotId'].includes(key))) fail('INVALID_TASK_REQUEST')
143
+ const requestKey = taskRef(value.requestKey), originBotId = taskRef(value.requesterBotId)
144
+ if (typeof readSubmittedPlan !== 'function') fail('BOT_ROUTING_STATUS_UNAVAILABLE')
145
+ const planId = submittedPlanId(originBotId, requestKey)
146
+ let plan
147
+ try { plan = await readSubmittedPlan(originBotId, planId) }
148
+ catch (error) {
149
+ if (error?.code !== 'TASK_PLAN_NOT_FOUND') throw error
150
+ // This is a point-in-time read. An original request may still commit later.
151
+ return { found: false, planId, originBotId, planState: null, packages: [] }
152
+ }
153
+ if (plan?.planId !== planId || plan?.ownerBotId !== originBotId) fail('BOT_ROUTING_SUBMISSION_IDENTITY_MISMATCH')
154
+ const accepted = assertPlanAccepted(plan)
155
+ return { found: true, accepted: true, planId, originBotId, planState: accepted.state,
156
+ packages: accepted.packages.map(({ id, targetBotId, taskId, attemptId, operationId, state }) => ({ id, targetBotId, taskId, attemptId, operationId, state })) }
157
+ },
158
+
135
159
  /**
136
160
  * Dispatch for real. The Host injects plan submission and the subject directory; without them
137
161
  * this refuses, because a dispatch that cannot create a task must not look like one.
@@ -153,6 +177,7 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
153
177
  if (!subjectFor(originBotId)) fail('ROUTING_SUBJECT_UNKNOWN')
154
178
  if (!owner && !readMainBotIds().includes(requesterBotId)) fail(`BOT_ROUTING_DISPATCH_DENIED:${requesterBotId}`)
155
179
  if (!owner && (typeof context.sessionId !== 'string' || context.sessionId === '')) fail('BOT_ROUTING_SESSION_REQUIRED')
180
+ if (payload.requestKey !== undefined && payload.requestKey !== '') taskRef(payload.requestKey)
156
181
  const needs = routingNeeds(payload)
157
182
  const decision = service.decide({ ...payload, needs, requesterBotId })
158
183
  if (!decision.chosen) fail(`BOT_ROUTING_NO_ELIGIBLE:${decision.rejected.map(row => row.reason).join(',') || 'none'}`)
@@ -184,7 +209,7 @@ export function createBotRoutingService({ bots, settingsFor, machineHealth, pric
184
209
  settle: typeof settle === 'function' ? report => settle(report, context) : null,
185
210
  deliver: typeof deliver === 'function' ? report => deliver(report, context) : null,
186
211
  newTaskId: typeof payload.requestKey === 'string' && payload.requestKey !== ''
187
- ? () => `route_${createHash('sha256').update(`${originBotId}:${payload.requestKey}`).digest('hex').slice(0, 48)}`
212
+ ? () => submittedPlanId(originBotId, payload.requestKey)
188
213
  : typeof newTaskId === 'function' ? newTaskId : undefined,
189
214
  })
190
215
  if (typeof onAccepted === 'function') await onAccepted(report, context)
@@ -16,7 +16,7 @@
16
16
  export const BOT_ROUTING_PRESETS = Object.freeze(['economy', 'balanced', 'quality'])
17
17
 
18
18
  const fail = code => { const error = new Error(code); error.code = code; throw error }
19
- const finite = value => (typeof value === 'number' && Number.isFinite(value) ? value : null)
19
+ const finite = (value, max = Infinity) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= max ? value : null)
20
20
  const list = value => (Array.isArray(value) ? value : [])
21
21
 
22
22
  /** Hard requirements a Bot must satisfy before it is even scored. */
@@ -53,7 +53,7 @@ export const botRejection = (candidate, task = {}) => rejection(candidate, task)
53
53
  /** Lower is better. Unknown cost sorts after known cost but is never silently free. */
54
54
  function score(candidate, preset, maxPrice) {
55
55
  const price = finite(candidate.pricePerMTokUsd)
56
- const quality = finite(candidate.quality)
56
+ const quality = finite(candidate.quality, 1)
57
57
  const load = finite(candidate.load) ?? 0
58
58
  // Cost is normalised against the most expensive eligible Bot, so one very expensive route
59
59
  // cannot swamp quality evidence: what matters is the ratio within this decision, not dollars.
@@ -91,9 +91,11 @@ export function routeBot({ task = {}, candidates = [], preset = 'balanced', limi
91
91
  const scored = []
92
92
  for (const candidate of eligible) {
93
93
  scored.push({ botId: candidate.botId, score: score(candidate, preset, maxPrice), model: candidate.model,
94
- pricePerMTokUsd: finite(candidate.pricePerMTokUsd), quality: finite(candidate.quality) })
94
+ pricePerMTokUsd: finite(candidate.pricePerMTokUsd), quality: finite(candidate.quality, 1) })
95
95
  }
96
- scored.sort((left, right) => left.score - right.score || String(left.botId).localeCompare(String(right.botId)))
96
+ scored.sort((left, right) => left.score - right.score
97
+ || Number(left.pricePerMTokUsd === null) - Number(right.pricePerMTokUsd === null)
98
+ || String(left.botId).localeCompare(String(right.botId)))
97
99
  const ranked = scored.slice(0, Math.max(1, limit))
98
100
  return {
99
101
  preset,
@@ -27,6 +27,7 @@ import { openBotSettings } from './bot-settings.mjs'
27
27
  import { openFundingAccounts } from './funding-accounts.mjs'
28
28
  import { createModelAccounting } from './model-accounting.mjs'
29
29
  import { createTaskService } from './task-service.mjs'
30
+ import { inspectProductTask, projectProductTask } from './task-product.mjs'
30
31
  import { createDesktopActions } from './desktop-actions.mjs'
31
32
  import { registerTaskTools } from './task-tools.mjs'
32
33
  import { createMrReadBackend } from './mr-read-backend.mjs'
@@ -343,19 +344,22 @@ export function startBotProduct(ctx, options = {}) {
343
344
  if (!taskService) throw new Error('TASK_SERVICE_UNAVAILABLE')
344
345
  return taskService.submitPlan(subject, request, caller)
345
346
  },
347
+ readSubmittedPlan: (originBotId, planId) => {
348
+ if (!journal) throw Object.assign(new Error('BOT_ROUTING_STATUS_UNAVAILABLE'), { code: 'BOT_ROUTING_STATUS_UNAVAILABLE' })
349
+ // Authenticated owner observation reads durable identities even if the Bot was paused.
350
+ return journal.getPlan(originBotId, planId)
351
+ },
346
352
  bindWorker: (binding, context) => commitWorkerBinding(binding, context),
347
353
  executionBackendFor: effect => effect === 'read' ? 'bot-turn-read' : 'bot-turn-write',
348
354
  onAccepted: report => turnBackends?.watchPlan(report),
349
355
  busyFor: botId => product?.rt.live?.get(product.rt.chatOf(botId))?.turn !== undefined,
350
- priceFor: route => {
351
- for (const bot of product?.lifecycle.committedBots() ?? []) {
352
- const settings = routingSettingsFor(bot.id)
353
- if (settings?.model?.provider === route.provider && settings.model.model === route.model) {
354
- const price = settings.funding?.pricing
355
- if (typeof price?.input === 'number' && typeof price.output === 'number') return (price.input + price.output) / 2
356
- }
357
- }
358
- return null
356
+ priceFor: (route, botId) => {
357
+ // Funding prices belong to the Bot binding, even when another Bot uses the same model.
358
+ const settings = routingSettingsFor(botId)
359
+ if (settings?.model?.provider !== route.provider || settings.model.model !== route.model) return null
360
+ const price = settings.funding?.pricing
361
+ if (!Number.isFinite(price?.input) || price.input < 0 || !Number.isFinite(price?.output) || price.output < 0) return null
362
+ return (price.input + price.output) / 2
359
363
  },
360
364
  })
361
365
  ctx.provide('botRouting', botRouting)
@@ -729,22 +733,10 @@ export function startBotProduct(ctx, options = {}) {
729
733
  // same single authority; a section without a runner stays blocked.
730
734
  ...(taskService ? { tasks: async ({ runtime, signal }) => {
731
735
  signal?.throwIfAborted(); await runtime.load()
732
- const items = []
733
- for (const bot of Object.values(runtime.state?.bots ?? {})) {
734
- for (const task of journal.list(bot.id)) {
735
- if (items.length >= TASK_READ_LIMIT) break
736
- items.push({ botId: task.ownerBotId, taskId: task.taskId, state: task.state, backend: task.input.backend,
737
- ...(task.planId?{planId:task.planId,logicalPackageId:task.logicalPackageId}:{}),
738
- location: task.input.target.location, machineId: task.input.target.machineId, workspaceId: task.input.target.workspaceId,
739
- createdAt: task.createdAt, updatedAt: task.updatedAt, deadlineAt: task.deadlineAt,
740
- attemptNumber: task.attemptNumber, operationId: task.operationId, attempts: task.attempts.length,
741
- legacyRunIds: task.legacyRunIds, result: task.result ? { code: task.result.code ?? task.result.summary?.code ?? null,
742
- ...(task.executionRef?.kind==='remote-operation'?{remote:{status:['succeeded','failed','cancelled','admitted','running','cancel_pending','unknown'].includes(task.result.summary?.status)?task.result.summary.status:'unknown',stopConfirmed:task.result.summary?.stopConfirmed===true,possiblyApplied:task.result.summary?.possiblyApplied!==false,...(/^[a-f0-9]{64}$/.test(task.result.summary?.sha256??'')?{sha256:task.result.summary.sha256}:{}),...(Number.isSafeInteger(task.result.summary?.bytes)?{bytes:task.result.summary.bytes}:{}),...(Number.isSafeInteger(task.result.summary?.exitCode)?{exitCode:task.result.summary.exitCode}:{}),...(task.result.summary?.notStarted===true?{notStarted:true}:{})}}:{}) } : null,
743
- reconciliation: task.reconciliation ? { at: task.reconciliation.at, outcome: task.reconciliation.outcome } : null })
744
- }
745
- }
736
+ const loaded = journal.productList(Object.values(runtime.state?.bots ?? {}).map(bot => bot.id), { limit: TASK_READ_LIMIT })
737
+ const items = loaded.items.map(projectProductTask)
746
738
  const plans=Object.values(runtime.state?.bots??{}).flatMap(bot=>journal.listPlans(bot.id))
747
- return { status: 'available', items, plans, backends: taskBackends.catalog() }
739
+ return { status: 'available', items, total: loaded.total, truncated: loaded.truncated, plans, backends: taskBackends.catalog() }
748
740
  } } : {}),
749
741
  // N07: the usage projection reads the durable source receipts and the
750
742
  // funding totals from the single authority; an unknown amount stays
@@ -837,6 +829,7 @@ export function startBotProduct(ctx, options = {}) {
837
829
  // action. Registering it here is what makes the routing card work at runtime; the owner
838
830
  // RPC methods are a second door onto the same service.
839
831
  'botRoutingInspect': async () => ({ ...botRouting.inspect(), settingsRevision: routingSettings.get().revision }),
832
+ 'botRouting.status': (payload, context) => botRouting.submissionStatus(payload, context),
840
833
  'botRouting.save': async (payload, context) => {
841
834
  const saved = routingSettings.save(payload, context)
842
835
  return { ...botRouting.inspect(), settingsRevision: saved.revision }
@@ -873,6 +866,7 @@ export function startBotProduct(ctx, options = {}) {
873
866
  // N09: owner task management runs on the owning Bot's own subject, so
874
867
  // the Bot's committed grant authorizes every cancel/retry/reconcile.
875
868
  ...(taskService ? {
869
+ 'task.inspect': payload => inspectProductTask({ service: taskService, journal, subject: taskOwner(payload), taskId: payload?.taskId }),
876
870
  // Bot routing turns a preview into a real package: the decision names the Bot, the
877
871
  // worker binding is committed first, and only then is the plan submitted. A failure in
878
872
  // either step leaves no half-dispatched task behind.
@@ -541,6 +541,29 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
541
541
  })
542
542
  },
543
543
  events(owner,id) {owned(owner,id);return stmt('SELECT * FROM task_events WHERE task_id=? ORDER BY seq').all(id)},
544
+ timeline(owner,id,{limit=80}={}) {
545
+ owned(owner,id)
546
+ if(!Number.isSafeInteger(limit)||limit<1||limit>200)fail('INVALID_TASK_HISTORY_LIMIT')
547
+ const total=stmt('SELECT COUNT(*) AS n FROM task_events WHERE task_id=?').get(id).n
548
+ const items=stmt('SELECT * FROM task_events WHERE task_id=? ORDER BY seq DESC LIMIT ?').all(id,limit).reverse()
549
+ return {items,total,truncated:total>items.length}
550
+ },
551
+ // Product lists must not permanently hide new tasks behind a Bot's first 128 records.
552
+ // This read is global across the Host's known Bots; it never changes runner queue ordering.
553
+ productList(owners,{limit=512}={}) {
554
+ if(!Array.isArray(owners)||!Number.isSafeInteger(limit)||limit<1||limit>512)fail('INVALID_TASK_LIST')
555
+ const ids=[...new Set(owners.map(taskRef))]
556
+ if(!ids.length)return {items:[],total:0,truncated:false}
557
+ const visible=new Set(ids)
558
+ const total=stmt('SELECT owner_bot,COUNT(*) AS n FROM tasks GROUP BY owner_bot').all()
559
+ .reduce((sum,row)=>sum+(visible.has(row.owner_bot)?row.n:0),0)
560
+ // Filter owners before the limit, including when old/deleted Bots still have records.
561
+ const items=stmt(`SELECT id,owner_bot FROM tasks WHERE owner_bot IN (SELECT value FROM json_each(?))
562
+ ORDER BY CASE WHEN state IN ('needs-reconciliation','waiting-approval','waiting-budget','blocked') THEN 0
563
+ WHEN state IN ('ready','queued','running','cancelling','admitting') THEN 1 WHEN state='failed' THEN 2 ELSE 3 END,
564
+ updated_ms DESC,id LIMIT ?`).all(JSON.stringify(ids),limit).map(row=>view(row.owner_bot,row.id))
565
+ return {items,total,truncated:total>items.length}
566
+ },
544
567
  // One Bot's durable tasks, oldest first; the read model never invents a task.
545
568
  list(owner) {taskRef(owner);return stmt('SELECT id FROM tasks WHERE owner_bot=? ORDER BY created_ms,id LIMIT 128').all(owner).map(row=>view(owner,row.id))},
546
569
  close() {if(!closed){closed=true;if(!borrowed)db.close();onClose()}},
@@ -0,0 +1,49 @@
1
+ // Public task views contain execution identities and status evidence, never backend inputs,
2
+ // execution references, raw event details or reconciliation proofs.
3
+ const short = (value, limit = 160) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, limit) : ''
4
+ const code = value => typeof value === 'string' && /^[A-Z0-9_:-]{1,128}$/.test(value) ? value : null
5
+
6
+ export function projectProductTask(task) {
7
+ const input = task.input?.input ?? {}
8
+ const title = short(input.task || input.text || input.prompt) || (input.relativePath ? short(`文件操作:${input.relativePath}`) : '')
9
+ const remote = task.result?.summary
10
+ return {
11
+ botId: task.ownerBotId, taskId: task.taskId, packageId: task.packageId, attemptId: task.attemptId,
12
+ state: task.state, title, backend: task.input?.backend,
13
+ ...(task.planId ? { planId: task.planId, logicalPackageId: task.logicalPackageId } : {}),
14
+ location: task.input?.target?.location, machineId: task.input?.target?.machineId, workspaceId: task.input?.target?.workspaceId,
15
+ createdAt: task.createdAt, updatedAt: task.updatedAt, deadlineAt: task.deadlineAt,
16
+ attemptNumber: task.attemptNumber, operationId: task.operationId, attempts: task.attempts.length,
17
+ legacyRunIds: task.legacyRunIds,
18
+ result: task.result ? { code: code(task.result.code ?? remote?.code),
19
+ ...(task.executionRef?.kind === 'remote-operation' ? { remote: {
20
+ status: ['succeeded', 'failed', 'cancelled', 'admitted', 'running', 'cancel_pending', 'unknown'].includes(remote?.status) ? remote.status : 'unknown',
21
+ stopConfirmed: remote?.stopConfirmed === true, possiblyApplied: remote?.possiblyApplied !== false,
22
+ ...(/^[a-f0-9]{64}$/.test(remote?.sha256 ?? '') ? { sha256: remote.sha256 } : {}),
23
+ ...(Number.isSafeInteger(remote?.bytes) ? { bytes: remote.bytes } : {}),
24
+ ...(Number.isSafeInteger(remote?.exitCode) ? { exitCode: remote.exitCode } : {}),
25
+ ...(remote?.notStarted === true ? { notStarted: true } : {}),
26
+ } } : {}),
27
+ } : null,
28
+ reconciliation: task.reconciliation ? { at: task.reconciliation.at, outcome: task.reconciliation.outcome } : null,
29
+ }
30
+ }
31
+
32
+ /** The service checks the Bot's committed task/read grant and the task's owner first. */
33
+ export function inspectProductTask({ service, journal, subject, taskId }) {
34
+ const task = service.get(subject, { taskId })
35
+ const history = journal.timeline(task.ownerBotId, task.taskId)
36
+ const attempts = task.attempts.map(row => ({ attemptId: row.id, number: row.number, state: row.state,
37
+ operationId: row.operation_id, startedAt: row.started_ms, finishedAt: row.finished_ms }))
38
+ const byId = new Map(attempts.map(row => [row.attemptId, row]))
39
+ return { task: projectProductTask(task), attempts, timeline: {
40
+ total: history.total, truncated: history.truncated,
41
+ items: history.items.map(row => {
42
+ const detail = JSON.parse(row.detail), attempt = byId.get(row.attempt_id)
43
+ return { seq: row.seq, kind: row.kind, at: row.at_ms, attemptId: row.attempt_id,
44
+ attemptNumber: attempt?.number ?? null, operationId: attempt?.operationId ?? null,
45
+ outcome: ['ready', 'running', 'succeeded', 'failed', 'cancelled', 'blocked', 'skipped', 'waiting-budget', 'needs-reconciliation', 'unknown'].includes(detail.outcome) ? detail.outcome : null,
46
+ code: code(detail.code) }
47
+ }),
48
+ } }
49
+ }
@@ -1,16 +1,51 @@
1
1
  import { build } from 'esbuild'
2
- import { copyFile, mkdir, lstat, rm } from 'node:fs/promises'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { mkdir, lstat, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
3
4
  import { fileURLToPath } from 'node:url'
4
- import { resolve, relative } from 'node:path'
5
+ import { basename, dirname, join, resolve, relative } from 'node:path'
6
+ import { setTimeout as sleep } from 'node:timers/promises'
5
7
  import assert from 'node:assert/strict'
6
- const root=fileURLToPath(new URL('.', import.meta.url))
7
- const output=resolve(root,'dist')
8
- assert.equal(relative(resolve(root),output),'dist','Build output must remain in the Agent dist directory')
9
- const previous=await lstat(output).catch(error=>{if(error.code==='ENOENT')return null;throw error})
10
- assert.ok(!previous?.isSymbolicLink(),'Refusing to replace a linked build output')
11
- await rm(output,{recursive:true,force:true})
12
- const options={absWorkingDir:root,bundle:true,packages:'external',platform:'node',format:'esm',target:'node22',sourcemap:true}
13
- await build({...options,entryPoints:['src/index.ts','src/cli.ts'],outbase:'src',outdir:'dist',outExtension:{'.js':'.mjs'}})
14
- await mkdir(`${root}dist`,{recursive:true})
15
- await build({...options,entryPoints:['../packages/capability-broker/src/cli-stdio-bridge.mjs'],outfile:'dist/cli-stdio-bridge.mjs'})
16
- for(const helper of ['native-peer.ps1','native-peer.py'])await copyFile(`${root}../packages/cli-runtime/src/${helper}`,`${root}dist/${helper}`)
8
+
9
+ const root = fileURLToPath(new URL('.', import.meta.url))
10
+ const output = resolve(root, 'dist')
11
+ assert.equal(relative(resolve(root), output), 'dist', 'Build output must remain in the Agent dist directory')
12
+ async function checkOutput() {
13
+ const previous = await lstat(output).catch(error => { if (error.code === 'ENOENT') return null; throw error })
14
+ assert.ok(!previous?.isSymbolicLink(), 'Refusing to replace a linked build output')
15
+ assert.ok(!previous || previous.isDirectory(), 'Agent build output must be a directory')
16
+ }
17
+ await checkOutput()
18
+
19
+ // Compile everything before touching the published build. Native tests run separate builders
20
+ // while other processes import or launch these entry points; deleting dist creates an ENOENT race.
21
+ const options = { absWorkingDir: root, bundle: true, packages: 'external', platform: 'node', format: 'esm', target: 'node22', sourcemap: true, write: false }
22
+ const main = await build({ ...options, entryPoints: ['src/index.ts', 'src/cli.ts'], outbase: 'src', outdir: 'dist', outExtension: { '.js': '.mjs' } })
23
+ const bridge = await build({ ...options, entryPoints: ['../packages/capability-broker/src/cli-stdio-bridge.mjs'], outfile: 'dist/cli-stdio-bridge.mjs' })
24
+ const files = [...main.outputFiles, ...bridge.outputFiles]
25
+ for (const helper of ['native-peer.ps1', 'native-peer.py']) {
26
+ files.push({ path: join(output, helper), contents: await readFile(resolve(root, '../packages/cli-runtime/src', helper)) })
27
+ }
28
+ for (const file of files) assert.equal(dirname(resolve(file.path)), output, 'Agent build files must remain directly inside dist')
29
+ await checkOutput()
30
+ await mkdir(output, { recursive: true })
31
+ const previousFiles = await readdir(output, { withFileTypes: true })
32
+ assert.ok(previousFiles.every(file => !file.isDirectory()), 'Unexpected directory in flat Agent build output')
33
+ for (const file of files) {
34
+ const bytes = Buffer.from(file.contents)
35
+ const previous = await readFile(file.path).catch(error => { if (error.code === 'ENOENT') return null; throw error })
36
+ if (previous?.equals(bytes)) continue
37
+ // Temporary files stay outside dist so another builder cannot prune or package them.
38
+ const temporary = join(root, `.agent-build-${randomUUID()}.tmp`)
39
+ try {
40
+ await writeFile(temporary, bytes, { flag: 'wx', mode: bytes.subarray(0, 2).toString() === '#!' ? 0o755 : 0o644 })
41
+ for (let attempt = 0; ; attempt++) {
42
+ try { await rename(temporary, file.path); break }
43
+ catch (error) {
44
+ if (attempt >= 5 || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error
45
+ await sleep(50)
46
+ }
47
+ }
48
+ } finally { await rm(temporary, { force: true }) }
49
+ }
50
+ const names = new Set(files.map(file => basename(file.path)))
51
+ for (const file of previousFiles) if (!names.has(file.name)) await rm(join(output, file.name), { force: true })