@ohmynpm/dsh-baton 0.0.0-stage → 0.2.1

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/session.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Local start state machine for an accepted Baton task.
3
+ *
4
+ * The server's `accepted` only means the recipient agreed to take the task.
5
+ * Turning that into a Session is a multi-step local operation, and every step
6
+ * can die between "recorded" and "done". This module is the single place that
7
+ * orders those steps and records where they stopped.
8
+ */
9
+ import { randomUUID } from 'node:crypto'
10
+ import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
11
+ import { dirname } from 'node:path'
12
+
13
+ export const PHASE = Object.freeze({
14
+ unsubmitted: 'unsubmitted',
15
+ creating: 'creating',
16
+ submitting: 'submitting',
17
+ submitted: 'submitted',
18
+ needsReview: 'needs-review',
19
+ handled: 'handled',
20
+ })
21
+
22
+ export class BatonStartError extends Error {
23
+ constructor(code, message, record) {
24
+ super(message)
25
+ this.name = 'BatonStartError'
26
+ this.code = code
27
+ this.record = record
28
+ }
29
+ }
30
+
31
+ /** Atomic, 0600 JSON state file. A corrupt file stops startup instead of resetting. */
32
+ export class LocalState {
33
+ constructor(path) {
34
+ this.path = path
35
+ this.data = { version: 1, tasks: {}, sends: {} }
36
+ this.writing = Promise.resolve()
37
+ }
38
+
39
+ async load() {
40
+ try {
41
+ const raw = await readFile(this.path, 'utf8')
42
+ const parsed = JSON.parse(raw)
43
+ if (parsed?.version !== 1 || typeof parsed.tasks !== 'object' || parsed.tasks === null || Array.isArray(parsed.tasks)) {
44
+ throw new Error('unsupported local state format')
45
+ }
46
+ if (parsed.sends !== undefined && (typeof parsed.sends !== 'object' || parsed.sends === null || Array.isArray(parsed.sends))) {
47
+ throw new Error('unsupported outgoing state format')
48
+ }
49
+ for (const [taskId, record] of Object.entries(parsed.tasks)) {
50
+ if (record?.taskId !== taskId || !Object.values(PHASE).includes(record.phase)) throw new Error('invalid task checkpoint')
51
+ }
52
+ this.data = { ...parsed, sends: parsed.sends ?? {} }
53
+ if (Object.values(parsed.tasks).some(record => [PHASE.creating, PHASE.submitting].includes(record.phase))) {
54
+ await this.update(data => ({
55
+ ...data,
56
+ tasks: Object.fromEntries(Object.entries(data.tasks).map(([id, record]) => [id, [PHASE.creating, PHASE.submitting].includes(record.phase)
57
+ ? { ...record, phase: PHASE.needsReview, error: '上次启动被中断,请先在 DSH 中核对会话及消息。' }
58
+ : record])),
59
+ }))
60
+ }
61
+ } catch (error) {
62
+ if (error?.code === 'ENOENT') return
63
+ throw new BatonStartError('state-corrupt', '本地启动记录无法读取,已停止启动以避免重复执行。请检查记录文件和权限。', undefined)
64
+ }
65
+ }
66
+
67
+ get(taskId) {
68
+ return this.data.tasks[taskId]
69
+ }
70
+
71
+ put(record) {
72
+ return this.update(data => ({ ...data, tasks: { ...data.tasks, [record.taskId]: { ...record } } }))
73
+ }
74
+
75
+ /** Serialize checkpoints and publish memory only after the atomic disk commit. */
76
+ update(change) {
77
+ const operation = this.writing.then(async () => {
78
+ const next = change(this.data)
79
+ await this.save(next)
80
+ this.data = next
81
+ })
82
+ this.writing = operation.catch(() => {})
83
+ return operation
84
+ }
85
+
86
+ /** Unique temporary files also isolate interrupted writes from the next process. */
87
+ async save(next) {
88
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 })
89
+ const temporary = `${this.path}.${randomUUID()}.tmp`
90
+ try {
91
+ const file = await open(temporary, 'wx', 0o600)
92
+ try {
93
+ await file.writeFile(JSON.stringify(next, null, 2))
94
+ await file.sync()
95
+ } finally { await file.close() }
96
+ await rename(temporary, this.path)
97
+ } finally { await rm(temporary, { force: true }) }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {object} deps
103
+ * @param {object} deps.adapter - create/rename/prompt/ensureWorkspace, already bound to DSH.
104
+ * @param {LocalState} deps.state
105
+ * @param {(error: unknown) => boolean} [deps.isDefinitelyNotEnqueued]
106
+ */
107
+ export function createStarter({ adapter, state, isDefinitelyNotEnqueued = () => false }) {
108
+ const active = new Set()
109
+ async function locked(taskId, action) {
110
+ if (active.has(taskId)) throw new BatonStartError('start-in-progress', '这条任务正在处理,请勿重复操作。', state.get(taskId))
111
+ active.add(taskId)
112
+ try { return await action() } finally { active.delete(taskId) }
113
+ }
114
+
115
+ async function saveRecord(record) {
116
+ await state.put(record)
117
+ return record
118
+ }
119
+
120
+ /** Create (or reuse) the Session, name it, and keep the record honest at every step. */
121
+ async function prepareSession(record, { title }) {
122
+ let sessionId = record.sessionId
123
+ if (!sessionId) {
124
+ const workspaceId = await adapter.ensureWorkspace(record.cwd)
125
+ record = await saveRecord({ ...record, phase: PHASE.creating })
126
+ let created
127
+ try {
128
+ created = await adapter.create({ workspaceId })
129
+ sessionId = created.sessionId
130
+ record = await saveRecord({ ...record, sessionId, phase: PHASE.unsubmitted })
131
+ } catch (error) {
132
+ const review = { ...record, ...(created?.sessionId ? { sessionId: created.sessionId } : {}), phase: PHASE.needsReview, error: '会话创建结果不明,请先在 DSH 会话列表中核对。' }
133
+ try { await saveRecord(review) } catch {
134
+ // The earlier creating checkpoint still blocks another creation after restart.
135
+ }
136
+ throw new BatonStartError('create-unknown', review.error, review)
137
+ }
138
+ }
139
+ try {
140
+ await adapter.rename({ sessionId, title })
141
+ } catch (error) {
142
+ record = await saveRecord({ ...record, phase: PHASE.unsubmitted, error: '会话已创建,但标题设置未完成,尚未提交消息。' })
143
+ throw new BatonStartError('rename-failed', '会话已保留,标题设置暂未完成。重试会沿用同一会话。', record)
144
+ }
145
+ return { record, sessionId }
146
+ }
147
+
148
+ async function submit(record, sessionId, brief) {
149
+ record = await saveRecord({ ...record, phase: PHASE.submitting, error: undefined })
150
+ let promptSucceeded = false
151
+ try {
152
+ await adapter.prompt({ sessionId, text: brief })
153
+ promptSucceeded = true
154
+ return await saveRecord({ ...record, phase: PHASE.submitted, error: undefined })
155
+ } catch (error) {
156
+ // Only a failure the adapter can prove "not enqueued" may be retried.
157
+ const phase = !promptSucceeded && isDefinitelyNotEnqueued(error) ? PHASE.unsubmitted : PHASE.needsReview
158
+ record = await saveRecord({ ...record, phase, error: error.message })
159
+ throw new BatonStartError(phase === PHASE.unsubmitted ? 'submit-retryable' : 'submit-unknown', error.message, record)
160
+ }
161
+ }
162
+
163
+ return {
164
+ isActive(taskId) { return active.has(taskId) },
165
+ /** First start after the server accepted the task. */
166
+ async start({ taskId, room, title, brief, cwd, respond }) {
167
+ return locked(taskId, async () => {
168
+ const existing = state.get(taskId)
169
+ if (existing?.phase === PHASE.submitted || existing?.phase === PHASE.handled) {
170
+ throw new BatonStartError('already-submitted', '这条任务已提交或已自行处理,不会再次启动。', existing)
171
+ }
172
+ if ([PHASE.needsReview, PHASE.creating, PHASE.submitting].includes(existing?.phase)) {
173
+ throw new BatonStartError('needs-review', '上次启动结果不明,请先在 DSH 中核对。', existing)
174
+ }
175
+
176
+ let record = existing ?? await saveRecord({
177
+ taskId,
178
+ room,
179
+ title,
180
+ cwd,
181
+ phase: PHASE.unsubmitted,
182
+ sessionId: undefined,
183
+ startedAt: Date.now(),
184
+ })
185
+
186
+ if (record.cwd !== cwd) {
187
+ if (record.sessionId) throw new BatonStartError('cwd-changed', '已有会话必须沿用原执行目录。', record)
188
+ record = await saveRecord({ ...record, cwd })
189
+ }
190
+
191
+ await respond()
192
+ const prepared = await prepareSession(record, { title: `[${room}] ${title}` })
193
+ return submit(prepared.record, prepared.sessionId, brief)
194
+ })
195
+ },
196
+
197
+ /** Continue an accepted task whose local phase is still `unsubmitted`. */
198
+ async resume({ taskId, brief, verify }) {
199
+ return locked(taskId, async () => {
200
+ const existing = state.get(taskId)
201
+ if (!existing) throw new BatonStartError('missing-record', '没有找到本地启动记录,请先在 DSH 中核对。', undefined)
202
+ if (existing.phase !== PHASE.unsubmitted) {
203
+ throw new BatonStartError('not-resumable', '这条任务当前不能继续启动,请先查看并核对状态。', existing)
204
+ }
205
+ await verify(existing)
206
+ const prepared = await prepareSession(existing, { title: `[${existing.room}] ${existing.title}` })
207
+ return submit(prepared.record, prepared.sessionId, brief)
208
+ })
209
+ },
210
+
211
+ /** The user confirmed they handled an unknown outcome outside the plugin. */
212
+ async markHandled(taskId) {
213
+ return locked(taskId, async () => {
214
+ const existing = state.get(taskId)
215
+ if (!existing) throw new BatonStartError('missing-record', '没有找到本地启动记录,请先在 DSH 中核对。', undefined)
216
+ if (![PHASE.needsReview, PHASE.creating, PHASE.submitting].includes(existing.phase)) throw new BatonStartError('not-reviewable', '这条任务不需要人工核对。', existing)
217
+ return saveRecord({ ...existing, phase: PHASE.handled, error: undefined })
218
+ })
219
+ },
220
+ }
221
+ }