@miphamai/cli 0.85.1 → 0.85.2
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/package.json +1 -1
- package/src/agent/agent-experience.ts +3 -2
- package/src/agent/background-registry.ts +7 -3
- package/src/agent/cross-session/discovery.ts +5 -12
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +3 -2
- package/src/agent/sub-agent.ts +44 -4
- package/src/agent-view/dashboard.tsx +22 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +46 -35
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/crsi-producer.ts +4 -2
- package/src/core/crsi-sandbox.ts +2 -1
- package/src/core/dream-engine.ts +5 -11
- package/src/core/engine.ts +9 -4
- package/src/core/error-signature-db.ts +3 -2
- package/src/core/eval-harness.ts +3 -3
- package/src/core/hooks-executor.ts +60 -2
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-rules.ts +99 -5
- package/src/core/permission.ts +6 -1
- package/src/core/rule-engine.ts +3 -2
- package/src/core/session-log.ts +48 -11
- package/src/core/session-store.ts +64 -44
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +6 -0
- package/src/mcp/oauth.ts +47 -5
- package/src/mcp/token-store.ts +10 -11
- package/src/providers/openai-compat.ts +11 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/regular-file.ts +63 -0
- package/src/shared/sanitize.ts +14 -2
- package/src/tools/agent/memory.ts +4 -2
- package/src/tools/agent/workflow.ts +6 -3
- package/src/tools/exec/task.ts +82 -30
- package/src/tools/scheduling/cron.ts +3 -9
- package/src/ui/app.tsx +48 -13
- package/src/ui/commands.ts +5 -2
- package/src/ui/ctrl-c-confirm.ts +63 -0
- package/src/workflow/journal.ts +80 -26
package/src/tools/exec/task.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import type { BackgroundAgentRegistry, BackgroundTask } from '../../agent/background-registry'
|
|
2
3
|
import { hasSupportedEvidenceSince } from '../../core/working-memory'
|
|
3
4
|
|
|
4
5
|
export interface Task {
|
|
@@ -14,9 +15,7 @@ export interface Task {
|
|
|
14
15
|
createdAt: string
|
|
15
16
|
/** 完成证据:completed 时是否有 supported 决策接地(软门,unverified 只标记不阻断)。 */
|
|
16
17
|
completionEvidence?: 'supported' | 'unverified'
|
|
17
|
-
/**
|
|
18
|
-
output?: string
|
|
19
|
-
/** Background task error message (set when a background agent fails). */
|
|
18
|
+
/** Set by the `stop` action on a running task ("Task stopped by user."). */
|
|
20
19
|
outputError?: string
|
|
21
20
|
}
|
|
22
21
|
|
|
@@ -36,6 +35,66 @@ function formatTask(t: Task): string {
|
|
|
36
35
|
return `[${t.status}] #${t.id}: ${t.subject}${owner}${blocks}${blocked}${meta}`
|
|
37
36
|
}
|
|
38
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Render a **background agent** the way the `output` action renders a session task.
|
|
40
|
+
*
|
|
41
|
+
* Background agents are not in the local `tasks` map: BackgroundAgentRegistry mints
|
|
42
|
+
* their ids in a separate space (`bg-<n>-<base36>`) and keeps results on the registry
|
|
43
|
+
* itself. The Agent tool hands those ids to the model with an explicit
|
|
44
|
+
* `Use Task output taskId="bg-…"` line, so this action has to answer for them —
|
|
45
|
+
* without this path the advertised call can only return `Task #bg-… not found`.
|
|
46
|
+
*/
|
|
47
|
+
function formatBackgroundOutput(bg: BackgroundTask): {
|
|
48
|
+
success: boolean
|
|
49
|
+
content: string
|
|
50
|
+
error?: string
|
|
51
|
+
} {
|
|
52
|
+
const head = `Background task ${bg.id}`
|
|
53
|
+
const meta = `Status: ${bg.status}\nAgent type: ${bg.agentType}\nTask: ${bg.description}`
|
|
54
|
+
|
|
55
|
+
if (bg.status === 'running') {
|
|
56
|
+
return {
|
|
57
|
+
success: true,
|
|
58
|
+
content: `${head} is still running — output not yet available.\n${meta}\n\nUse Task output again once it completes.`,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (bg.status === 'failed') {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
content: '',
|
|
66
|
+
error: `${head} failed: ${bg.error ?? '(no error recorded)'}`,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
success: true,
|
|
72
|
+
content: `── ${head} Output ──\n${meta}\n\n${(bg.result ?? '(no output recorded)').slice(0, 5000)}`,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Stop a running background agent through the registry that owns it. */
|
|
77
|
+
function stopBackgroundTask(
|
|
78
|
+
bg: BackgroundTask,
|
|
79
|
+
registry: BackgroundAgentRegistry,
|
|
80
|
+
): { success: boolean; content: string; error?: string } {
|
|
81
|
+
if (bg.status !== 'running') {
|
|
82
|
+
return {
|
|
83
|
+
success: true,
|
|
84
|
+
content: `Background task ${bg.id} is already ${bg.status}. Nothing to stop.`,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// `stop()` also returns false when the task is no longer running (it completed
|
|
88
|
+
// between the lookup above and this call), so the message must not claim why.
|
|
89
|
+
const aborted = registry.stop(bg.id)
|
|
90
|
+
return {
|
|
91
|
+
success: true,
|
|
92
|
+
content: aborted
|
|
93
|
+
? `Background task ${bg.id} stopped.\nTask: ${bg.description}`
|
|
94
|
+
: `Background task ${bg.id} was not stopped — it is no longer running.`,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
39
98
|
/** Check if a task is blocked — has unresolved dependencies. */
|
|
40
99
|
function isBlocked(task: Task): boolean {
|
|
41
100
|
if (task.blockedBy.length === 0) return false
|
|
@@ -238,11 +297,6 @@ export const taskTool: ToolDefinition = {
|
|
|
238
297
|
}
|
|
239
298
|
}
|
|
240
299
|
if (task.metadata) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`)
|
|
241
|
-
if (task.output) {
|
|
242
|
-
lines.push('')
|
|
243
|
-
lines.push('── Output ──')
|
|
244
|
-
lines.push(task.output.slice(0, 2000))
|
|
245
|
-
}
|
|
246
300
|
if (task.outputError) {
|
|
247
301
|
lines.push('')
|
|
248
302
|
lines.push(`── Error ──`)
|
|
@@ -320,7 +374,14 @@ export const taskTool: ToolDefinition = {
|
|
|
320
374
|
if (action === 'output') {
|
|
321
375
|
const taskId = params.taskId as string
|
|
322
376
|
const task = tasks.get(taskId)
|
|
323
|
-
if (!task)
|
|
377
|
+
if (!task) {
|
|
378
|
+
// Not a session task — it may be a background agent id (`bg-…`), which is
|
|
379
|
+
// the id space this action is advertised for. The registry is wired onto
|
|
380
|
+
// the tool context by the engine (`engine.ts` defaultToolContext).
|
|
381
|
+
const bg = _ctx.backgroundAgentRegistry?.get(taskId)
|
|
382
|
+
if (bg) return formatBackgroundOutput(bg)
|
|
383
|
+
return { success: false, content: '', error: `Task #${taskId} not found` }
|
|
384
|
+
}
|
|
324
385
|
|
|
325
386
|
if (task.status === 'pending') {
|
|
326
387
|
return {
|
|
@@ -336,13 +397,6 @@ export const taskTool: ToolDefinition = {
|
|
|
336
397
|
}
|
|
337
398
|
}
|
|
338
399
|
|
|
339
|
-
if (task.status === 'completed' && task.output) {
|
|
340
|
-
return {
|
|
341
|
-
success: true,
|
|
342
|
-
content: `── Task #${taskId} Output ──\nStatus: completed\nSubject: ${task.subject}\n\n${task.output.slice(0, 5000)}`,
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
400
|
if (task.status === 'failed' && task.outputError) {
|
|
347
401
|
return {
|
|
348
402
|
success: false,
|
|
@@ -361,7 +415,15 @@ export const taskTool: ToolDefinition = {
|
|
|
361
415
|
if (action === 'stop') {
|
|
362
416
|
const taskId = params.taskId as string
|
|
363
417
|
const task = tasks.get(taskId)
|
|
364
|
-
if (!task)
|
|
418
|
+
if (!task) {
|
|
419
|
+
// Background agent id (`bg-…`) — the other id space. The local branch
|
|
420
|
+
// below only marks a status; an actual running agent is aborted through
|
|
421
|
+
// the registry that owns it.
|
|
422
|
+
const registry = _ctx.backgroundAgentRegistry
|
|
423
|
+
const bg = registry?.get(taskId)
|
|
424
|
+
if (registry && bg) return stopBackgroundTask(bg, registry)
|
|
425
|
+
return { success: false, content: '', error: `Task #${taskId} not found` }
|
|
426
|
+
}
|
|
365
427
|
|
|
366
428
|
if (task.status === 'completed' || task.status === 'deleted') {
|
|
367
429
|
return {
|
|
@@ -376,22 +438,12 @@ export const taskTool: ToolDefinition = {
|
|
|
376
438
|
return { success: true, content: `Task #${taskId} cancelled (was pending).` }
|
|
377
439
|
}
|
|
378
440
|
|
|
379
|
-
// For in_progress tasks: mark as failed
|
|
380
|
-
//
|
|
441
|
+
// For in_progress session tasks: mark as failed. There is no process behind
|
|
442
|
+
// one — a *background agent* is aborted by stopBackgroundTask() above, which
|
|
443
|
+
// the registry owns.
|
|
381
444
|
task.status = 'failed'
|
|
382
445
|
task.outputError = 'Task stopped by user.'
|
|
383
446
|
|
|
384
|
-
// If BackgroundAgentRegistry is available in context, try to abort
|
|
385
|
-
if (_ctx.registry) {
|
|
386
|
-
// Signal to any background registry that this task should stop
|
|
387
|
-
// The background registry will be injected via the engine context
|
|
388
|
-
const bgRegistry = (_ctx as unknown as Record<string, unknown>).backgroundAgentRegistry as
|
|
389
|
-
{ stop(id: string): boolean } | undefined
|
|
390
|
-
if (bgRegistry) {
|
|
391
|
-
bgRegistry.stop(taskId)
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
447
|
return { success: true, content: `Task #${taskId} stopped.` }
|
|
396
448
|
}
|
|
397
449
|
|
|
@@ -1,11 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
readFileSync,
|
|
4
|
-
writeFileSync,
|
|
5
|
-
unlinkSync,
|
|
6
|
-
readdirSync,
|
|
7
|
-
existsSync,
|
|
8
|
-
} from 'node:fs'
|
|
1
|
+
import { mkdirSync, readFileSync, unlinkSync, readdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { atomicWriteFileSync } from '../../shared/atomic-write'
|
|
9
3
|
import { join } from 'node:path'
|
|
10
4
|
import { createHash } from 'node:crypto'
|
|
11
5
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
@@ -78,7 +72,7 @@ export function readAllJobs(): CronJob[] {
|
|
|
78
72
|
/** Persist a job (create or update) to its JSON file. */
|
|
79
73
|
export function writeJob(job: CronJob): void {
|
|
80
74
|
ensureCronDir()
|
|
81
|
-
|
|
75
|
+
atomicWriteFileSync(jobPath(job.id), JSON.stringify(job, null, 2), { mode: 0o644 })
|
|
82
76
|
}
|
|
83
77
|
|
|
84
78
|
/** Delete a job's file. Returns false when the job did not exist. */
|
package/src/ui/app.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from 'ink'
|
|
|
3
3
|
import TextInput from 'ink-text-input'
|
|
4
4
|
import { execSync } from 'node:child_process'
|
|
5
5
|
import { ErrorBoundary } from './error-boundary'
|
|
6
|
+
import { useCtrlCConfirm } from './ctrl-c-confirm'
|
|
6
7
|
import { formatThinking } from './thinking'
|
|
7
8
|
import type { QueryEngine } from '../core/engine'
|
|
8
9
|
import type { RemoteEngine } from '../daemon/remote-engine'
|
|
@@ -1136,22 +1137,55 @@ export function App({
|
|
|
1136
1137
|
[engine, mkCtx, runTurn],
|
|
1137
1138
|
)
|
|
1138
1139
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1140
|
+
/**
|
|
1141
|
+
* 关掉「最上面那一层」—— 密钥提示框 / 选择器 / 正在跑的回合。关掉了返回 true。
|
|
1142
|
+
*
|
|
1143
|
+
* Esc 与 Ctrl+C 共用这一份:在用户心里这两个键是同一件事(「停」),分成两套
|
|
1144
|
+
* 判断迟早会漂移。没有可关的东西 ⇒ false,由调用方决定下一步。
|
|
1145
|
+
*/
|
|
1146
|
+
const dismissTopmost = (): boolean => {
|
|
1147
|
+
if (apiKeyPrompt) {
|
|
1148
|
+
setApiKeyPrompt(null)
|
|
1149
|
+
setApiKeyInput('')
|
|
1150
|
+
return true
|
|
1151
|
+
}
|
|
1152
|
+
if (pickerOpen) {
|
|
1153
|
+
setPickerOpen(false)
|
|
1154
|
+
return true
|
|
1155
|
+
}
|
|
1156
|
+
if (isLoading && abortRef.current) {
|
|
1157
|
+
abortRef.current.abort()
|
|
1158
|
+
return true
|
|
1159
|
+
}
|
|
1160
|
+
return false
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Ctrl+C 的「再按一次才退」,语义与计时器见 ui/ctrl-c-confirm.ts
|
|
1164
|
+
const ctrlC = useCtrlCConfirm()
|
|
1165
|
+
|
|
1166
|
+
useInput((input, key) => {
|
|
1167
|
+
// Ctrl+C:先关最上面那一层;没得关时**不退出**,只提示「再按一次」。
|
|
1168
|
+
//
|
|
1169
|
+
// 从前这里没有这个分支,而 Ink 自己的 Ctrl+C 处理(`exitOnCtrlC`,默认
|
|
1170
|
+
// true)在按键**到达任何 handler 之前**就把进程退了 —— 于是对话框里一次
|
|
1171
|
+
// 误按就把整个会话带走。`render()` 三处均已传 `exitOnCtrlC: false`,
|
|
1172
|
+
// 否则这个分支永远不会被调用(见 src/index.tsx 的注释)。
|
|
1173
|
+
if (key.ctrl && input === 'c') {
|
|
1174
|
+
if (dismissTopmost()) {
|
|
1175
|
+
// 这一按被「关掉最上面一层」消费掉了 —— 用户想的是「停」,不是「退」,
|
|
1176
|
+
// 所以待确认窗口要一并撤掉,免得下一次误按直接退出。
|
|
1177
|
+
ctrlC.reset()
|
|
1149
1178
|
return
|
|
1150
1179
|
}
|
|
1151
|
-
if (
|
|
1152
|
-
|
|
1153
|
-
return
|
|
1180
|
+
if (ctrlC.isArmed()) {
|
|
1181
|
+
process.exit(0)
|
|
1154
1182
|
}
|
|
1183
|
+
ctrlC.arm()
|
|
1184
|
+
return
|
|
1185
|
+
}
|
|
1186
|
+
// Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
|
|
1187
|
+
if (key.escape) {
|
|
1188
|
+
if (dismissTopmost()) return
|
|
1155
1189
|
// Otherwise let InputBar handle Escape (clear draft)
|
|
1156
1190
|
return
|
|
1157
1191
|
}
|
|
@@ -1372,6 +1406,7 @@ export function App({
|
|
|
1372
1406
|
{' · '}
|
|
1373
1407
|
{t('ui.status.left_for_agents')}
|
|
1374
1408
|
</Text>
|
|
1409
|
+
{ctrlC.armed && <Text color="yellow"> {t('ui.status.ctrl_c_again')}</Text>}
|
|
1375
1410
|
</Box>
|
|
1376
1411
|
</Box>
|
|
1377
1412
|
|
package/src/ui/commands.ts
CHANGED
|
@@ -2504,7 +2504,8 @@ const marketplaceCmd: CommandHandler = async (_ctx, args) => {
|
|
|
2504
2504
|
isValidMarketplaceRef,
|
|
2505
2505
|
MARKETPLACES_PATH,
|
|
2506
2506
|
} = await import('../skills/marketplace')
|
|
2507
|
-
const { readFileSync,
|
|
2507
|
+
const { readFileSync, existsSync, mkdirSync } = await import('node:fs')
|
|
2508
|
+
const { atomicWriteFileSync } = await import('../shared/atomic-write')
|
|
2508
2509
|
const { dirname } = await import('node:path')
|
|
2509
2510
|
|
|
2510
2511
|
const readSources = () =>
|
|
@@ -2537,7 +2538,9 @@ const marketplaceCmd: CommandHandler = async (_ctx, args) => {
|
|
|
2537
2538
|
const changed = 'added' in result ? result.added : result.removed
|
|
2538
2539
|
try {
|
|
2539
2540
|
mkdirSync(dirname(MARKETPLACES_PATH), { recursive: true })
|
|
2540
|
-
|
|
2541
|
+
atomicWriteFileSync(MARKETPLACES_PATH, JSON.stringify(result.sources, null, 2), {
|
|
2542
|
+
mode: 0o644,
|
|
2543
|
+
})
|
|
2541
2544
|
} catch (err) {
|
|
2542
2545
|
return { content: `❌ Failed to save marketplaces: ${String(err)}` }
|
|
2543
2546
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ctrl+C 的「再按一次才退出」窗口。
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要它:Ink 默认的 Ctrl+C 语义是**立刻退出**,而 TUI 里 Ctrl+C 的合
|
|
7
|
+
* 理语义是「停下手上这件事」—— 对话框开着、回合跑着的时候,误按一次不该把
|
|
8
|
+
* 整个会话带走。所以 `render()` 处一律传 `exitOnCtrlC: false`(见
|
|
9
|
+
* `src/index.tsx`),退出的决定权收回到这里:第一次按只「上膛」,窗口内再按
|
|
10
|
+
* 一次才真退。
|
|
11
|
+
*
|
|
12
|
+
* 两个入口(主界面 `ui/app.tsx`、看板 `agent-view/dashboard.tsx`)共用这一份,
|
|
13
|
+
* 而不是各写一遍 —— 同一条语义有两份实现,迟早会长成两个东西。
|
|
14
|
+
*
|
|
15
|
+
* 注意 `isArmed()` 与 `armed` 是**两条路**,不是冗余:
|
|
16
|
+
* - 判据要走 `isArmed()`(ref)—— 在同一个按键回调里 `arm()` 之后 state 还没
|
|
17
|
+
* 重渲染,用 state 读的话第二次按键看到的仍是 false,就永远退不出去;
|
|
18
|
+
* - `armed`(state)只给渲染用(页脚那句提示)。
|
|
19
|
+
*/
|
|
20
|
+
export interface CtrlCConfirm {
|
|
21
|
+
/** 渲染用:当前是否处于「待确认」窗口内。 */
|
|
22
|
+
armed: boolean
|
|
23
|
+
/** 判据用:同步读数,同一次按键回调里 `arm()` 之后立刻为 true。 */
|
|
24
|
+
isArmed: () => boolean
|
|
25
|
+
/** 上膛:进入待确认窗口,`windowMs` 后自动撤。 */
|
|
26
|
+
arm: () => void
|
|
27
|
+
/** 撤膛:关掉窗口并清掉计时器(例如这次按键被别的事情消费掉了)。 */
|
|
28
|
+
reset: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useCtrlCConfirm(windowMs = 2000): CtrlCConfirm {
|
|
32
|
+
const armedRef = useRef(false)
|
|
33
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
34
|
+
const [armed, setArmed] = useState(false)
|
|
35
|
+
|
|
36
|
+
const clear = useCallback(() => {
|
|
37
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
38
|
+
timerRef.current = null
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
const reset = useCallback(() => {
|
|
42
|
+
clear()
|
|
43
|
+
armedRef.current = false
|
|
44
|
+
setArmed(false)
|
|
45
|
+
}, [clear])
|
|
46
|
+
|
|
47
|
+
const arm = useCallback(() => {
|
|
48
|
+
clear()
|
|
49
|
+
armedRef.current = true
|
|
50
|
+
setArmed(true)
|
|
51
|
+
timerRef.current = setTimeout(() => {
|
|
52
|
+
armedRef.current = false
|
|
53
|
+
setArmed(false)
|
|
54
|
+
}, windowMs)
|
|
55
|
+
}, [clear, windowMs])
|
|
56
|
+
|
|
57
|
+
// 卸载时撤掉计时器 —— 否则它到点后会往已经卸掉的组件里 setState。
|
|
58
|
+
useEffect(() => clear, [clear])
|
|
59
|
+
|
|
60
|
+
const isArmed = useCallback(() => armedRef.current, [])
|
|
61
|
+
|
|
62
|
+
return { armed, isArmed, arm, reset }
|
|
63
|
+
}
|
package/src/workflow/journal.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
mkdirSync,
|
|
3
|
-
writeFileSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
existsSync,
|
|
6
|
-
appendFileSync,
|
|
7
|
-
readdirSync,
|
|
8
|
-
} from 'node:fs'
|
|
1
|
+
import { mkdirSync, existsSync, readdirSync } from 'node:fs'
|
|
9
2
|
import { join } from 'node:path'
|
|
3
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
4
|
+
import { appendRegularFileSync, readRegularFileSync } from '../shared/regular-file'
|
|
10
5
|
import { miphamHome } from '../core/paths.ts'
|
|
11
6
|
|
|
12
7
|
const WORKFLOW_DIR = miphamHome('workflows')
|
|
@@ -33,15 +28,23 @@ export function createJournal(runId: string, script: string): string {
|
|
|
33
28
|
const dir = join(WORKFLOW_DIR, runId)
|
|
34
29
|
mkdirSync(dir, { recursive: true })
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
writeFileSync
|
|
38
|
-
|
|
31
|
+
// 三份都走助手:新建 run 目录里本来没有旧内容可丢,但**路径上可能被放了个 FIFO**
|
|
32
|
+
// (`writeFileSync` 会打开它写 ⇒ 干等到有读者为止)。`atomicWriteFileSync` 的 rename
|
|
33
|
+
// 与 `appendRegularFileSync` 的类型闸都不打开目标,于是在这里也一并关掉。
|
|
34
|
+
atomicWriteFileSync(join(dir, 'script.js'), script, { mode: 0o644 })
|
|
35
|
+
if (!appendRegularFileSync(join(dir, 'journal.jsonl'), '')) {
|
|
36
|
+
throw new Error(`workflow journal for run "${runId}" is not appendable`)
|
|
37
|
+
}
|
|
38
|
+
atomicWriteFileSync(join(dir, 'state.json'), JSON.stringify({ seq: 0, phases: [] }))
|
|
39
39
|
|
|
40
40
|
return dir
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Append an agent call to the journal. Returns the new sequence number.
|
|
45
|
+
*
|
|
46
|
+
* 先 append journal(权威记录)、后写 state(它的影子)—— 顺序有意义:中途被打断时
|
|
47
|
+
* 唯一的后果是影子落后,而不是记录丢失。
|
|
45
48
|
*/
|
|
46
49
|
export function appendJournal(runId: string, entry: Omit<JournalEntry, 'seq'>): number {
|
|
47
50
|
const dir = join(WORKFLOW_DIR, runId)
|
|
@@ -50,32 +53,84 @@ export function appendJournal(runId: string, entry: Omit<JournalEntry, 'seq'>):
|
|
|
50
53
|
const seq = state.seq + 1
|
|
51
54
|
const fullEntry: JournalEntry = { seq, ...entry }
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
// 权威记录写不进去 = 这个 run 没法记账(含路径上是个 FIFO):**不做影子**、当场说清,
|
|
57
|
+
// 好过把 state 推进到一个 journal 里不存在的 seq 上。
|
|
58
|
+
if (!appendRegularFileSync(join(dir, 'journal.jsonl'), JSON.stringify(fullEntry) + '\n')) {
|
|
59
|
+
throw new Error(`workflow journal for run "${runId}" is not appendable`)
|
|
60
|
+
}
|
|
54
61
|
|
|
55
62
|
state.seq = seq
|
|
56
|
-
|
|
63
|
+
atomicWriteFileSync(join(dir, 'state.json'), JSON.stringify(state))
|
|
57
64
|
|
|
58
65
|
return seq
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/**
|
|
62
69
|
* Load all journal entries for a run. Returns empty array if run not found.
|
|
70
|
+
*
|
|
71
|
+
* **逐行**解析:`appendFileSync` 被打断会在尾部留下半截行,而从前一个半截行让整份
|
|
72
|
+
* 读取抛 `SyntaxError`(`/workflow` 那条链上没有兜它)。坏行丢掉、其余全留 —— 一份
|
|
73
|
+
* 日志里少一行,好过整份读不出来。
|
|
63
74
|
*/
|
|
64
75
|
export function loadJournal(runId: string): JournalEntry[] {
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
.
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
const raw = readRegularFileSync(join(WORKFLOW_DIR, runId, 'journal.jsonl'))
|
|
77
|
+
if (raw === null) return []
|
|
78
|
+
|
|
79
|
+
const entries: JournalEntry[] = []
|
|
80
|
+
for (const line of raw.split('\n')) {
|
|
81
|
+
if (!line.trim()) continue
|
|
82
|
+
try {
|
|
83
|
+
const parsed: unknown = JSON.parse(line)
|
|
84
|
+
if (isJournalEntry(parsed)) entries.push(parsed)
|
|
85
|
+
} catch {
|
|
86
|
+
// 半截行(append 被打断)/坏行 —— 只丢这一行
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return entries
|
|
73
90
|
}
|
|
74
91
|
|
|
92
|
+
function isJournalEntry(e: unknown): e is JournalEntry {
|
|
93
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) return false
|
|
94
|
+
const ev = e as Record<string, unknown>
|
|
95
|
+
return (
|
|
96
|
+
typeof ev.seq === 'number' && (ev.type === 'agent' || ev.type === 'phase' || ev.type === 'log')
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 读 state.json。
|
|
102
|
+
*
|
|
103
|
+
* 从前这里是**裸**的 `JSON.parse(readFileSync(...))`:state.json 是非原子写的,半截写
|
|
104
|
+
* 会让 `appendJournal` 当场抛 `SyntaxError`,整个 run 停摆 —— 而 state 只是**派生**信息。
|
|
105
|
+
* 所以坏掉/缺字段时把 `seq` 从 journal 尾行**重新推出来**,而不是归零:归零会让后续
|
|
106
|
+
* append 复用已经在用的序号,而那正是这份文件存在的意义。
|
|
107
|
+
*/
|
|
75
108
|
function readState(runId: string): JournalState {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
109
|
+
const raw = readRegularFileSync(join(WORKFLOW_DIR, runId, 'state.json'))
|
|
110
|
+
if (raw !== null) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed: unknown = JSON.parse(raw)
|
|
113
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
114
|
+
const { seq, phases } = parsed as { seq?: unknown; phases?: unknown }
|
|
115
|
+
if (typeof seq === 'number' && Number.isFinite(seq)) {
|
|
116
|
+
return {
|
|
117
|
+
seq,
|
|
118
|
+
phases: Array.isArray(phases)
|
|
119
|
+
? phases.filter((p): p is string => typeof p === 'string')
|
|
120
|
+
: [],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// 半截写/坏形状 —— 落到下面按 journal 推。
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { seq: lastJournalSeq(runId), phases: [] }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** journal 尾行的 seq —— 权威记录在 journal.jsonl,state.json 只是它的影子。 */
|
|
132
|
+
function lastJournalSeq(runId: string): number {
|
|
133
|
+
return loadJournal(runId).reduce((max, e) => (e.seq > max ? e.seq : max), 0)
|
|
79
134
|
}
|
|
80
135
|
|
|
81
136
|
/**
|
|
@@ -83,8 +138,7 @@ function readState(runId: string): JournalState {
|
|
|
83
138
|
*/
|
|
84
139
|
export function loadScript(runId: string): string {
|
|
85
140
|
const scriptPath = join(WORKFLOW_DIR, runId, 'script.js')
|
|
86
|
-
|
|
87
|
-
return readFileSync(scriptPath, 'utf-8')
|
|
141
|
+
return readRegularFileSync(scriptPath) ?? ''
|
|
88
142
|
}
|
|
89
143
|
|
|
90
144
|
/**
|