xitto-kernel 0.8.4 → 0.8.5
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/CHANGELOG.md +9 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/app/server.js +30 -7
- package/src/app/web/index.html +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.5
|
|
4
|
+
|
|
5
|
+
- **許願台重啟後歷史還在(持久化)**:原本任務清單與對話 session 都是 in-memory,重啟全沒。改成落地:
|
|
6
|
+
- **任務清單** → `.xitto-server/tasks/<id>.json`(每任務一檔,狀態變更時覆寫),啟動載回 → **歷史成品重啟後自動顯示**
|
|
7
|
+
- **對話 session** → `.xitto-server/sessions/<id>.json`,啟動載回 → **重啟後仍能「繼續/調整」**(對話脈絡跨重啟)
|
|
8
|
+
- **重啟收尾**:載入時還停在 `running`/`queued`/`needs-input` 的(agent 已隨進程消失)標 `interrupted`「已中斷(重啟)」
|
|
9
|
+
- 對標 Claude Code「對話自動落地」;但許願台是**自動顯示歷史**(成品清單),非明確 `--resume`(它是 chat,單位不同)
|
|
10
|
+
- 1 個新測試(落地/載回/interrupted)+ 真實端到端:跑任務→重啟(新 server 同 baseDir)→歷史顯示 + 接續對話寫出「重啟前只在對話講過的偏好 42」。測試 189/189
|
|
11
|
+
|
|
3
12
|
## 0.8.4
|
|
4
13
|
|
|
5
14
|
- **桌面雙欄佈局(善用寬螢幕)**:原本單條 760px 窄欄、左右大量留白。改用 CSS grid 雙欄:
|
package/README.md
CHANGED
|
@@ -118,6 +118,8 @@ npm run serve:local # = LOCAL=1 SANDBOX=off,token 預設
|
|
|
118
118
|
|
|
119
119
|
**本地就地模式(像 Claude Code 改你選的真實資料夾)**:`XITTO_SERVER_LOCAL=1` 時,網頁多一個「**📁 選資料夾**」鈕——**用點的**從家目錄瀏覽進你的真實資料夾並選定(不用打路徑;瀏覽器拿不到絕對路徑,所以由 local server 端列資料夾),或「新專案」直接貼絕對路徑也行。任務就**就地改那個資料夾的檔**(不另開隔離副本),工作台列的也是它。這把「許願台(隔離,服務非技術使用者)」和「Claude Code(就地,改你現有的 codebase)」兩個模型打通:**本機自用想就地 → 給路徑;隔離/託管 → 給名稱**。**安全**:只在 `local` 模式才認絕對路徑;**託管模式收到絕對路徑會被消毒成管理空間,不會逃逸到主機任意路徑**。
|
|
120
120
|
|
|
121
|
+
**重啟後歷史還在(持久化)**:任務清單落地 `.xitto-server/tasks/`、對話 session 落地 `.xitto-server/sessions/`,啟動時載回——所以**重啟後歷史成品自動顯示、舊成品仍能「繼續/調整」**(對話脈絡也在)。重啟時還在跑/待答的任務會標「已中斷(重啟)」。對標 Claude Code「對話自動落地」,但許願台是**自動顯示歷史**(成品清單),而非 Claude Code 的明確 `--resume`。
|
|
122
|
+
|
|
121
123
|
**溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
|
|
122
124
|
|
|
123
125
|
零依賴單一 HTML(`src/app/web/index.html`),polling 不靠 SSE。token 注入頁面供同源呼叫——本地自用零設定;**正式部署請前置真實認證**。
|
package/package.json
CHANGED
package/src/app/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
|
|
4
4
|
// 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
|
|
5
5
|
import { createServer } from 'node:http';
|
|
6
|
-
import { mkdirSync, readFileSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { join, dirname, isAbsolute, relative, basename, resolve } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
@@ -100,12 +100,26 @@ export const mapEvent = (ev) => {
|
|
|
100
100
|
* @param {(task:object)=>void} [o.onFinish] 每個任務 settle 後呼叫(拿來發 webhook)
|
|
101
101
|
* @param {number} [o.maxEvents] 每任務保留最近幾筆事件(預設 500)
|
|
102
102
|
*/
|
|
103
|
-
export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500 } = {}) {
|
|
103
|
+
export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents = 500, persistDir } = {}) {
|
|
104
104
|
const tasks = new Map(); // id -> task
|
|
105
105
|
const queue = []; // 等待中的 task
|
|
106
106
|
const subs = new Map(); // id -> Set<(ev)=>void>
|
|
107
107
|
let active = 0;
|
|
108
108
|
|
|
109
|
+
// 持久化:每個任務落地一個 json(重啟後歷史成品還在)。runtime 欄位(_agent/events…)不存。
|
|
110
|
+
const snapshot = (t) => ({ id: t.id, status: t.status, spec: t.spec, result: t.result, error: t.error, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, progress: t.progress || null, pending: t.pending || null });
|
|
111
|
+
const persistTask = (t) => { if (!persistDir) return; try { mkdirSync(persistDir, { recursive: true }); writeFileSync(join(persistDir, t.id + '.json'), JSON.stringify(snapshot(t))); } catch { /* 略 */ } };
|
|
112
|
+
if (persistDir && existsSync(persistDir)) {
|
|
113
|
+
for (const f of readdirSync(persistDir).filter((x) => x.endsWith('.json')).sort()) {
|
|
114
|
+
try {
|
|
115
|
+
const t = JSON.parse(readFileSync(join(persistDir, f), 'utf8'));
|
|
116
|
+
if (['running', 'queued', 'needs-input'].includes(t.status)) { t.status = 'interrupted'; t.pending = null; } // 進程已死 → 標中斷
|
|
117
|
+
t.events = [];
|
|
118
|
+
tasks.set(t.id, t);
|
|
119
|
+
} catch { /* 壞檔略 */ }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', workspace: t.spec.workspace || 'default', goal: t.spec.goal || t.spec.input || '', sessionId: t.result?.sessionId || t.spec.sessionId || null, continued: !!t.spec.sessionId, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
|
|
110
124
|
|
|
111
125
|
const emit = (t, ev) => {
|
|
@@ -135,6 +149,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
135
149
|
const makeAsk = (t) => ({ question, options }) => {
|
|
136
150
|
t.status = 'needs-input'; t.pending = { question: String(question || ''), options: options || null };
|
|
137
151
|
emit(t, { type: 'needs_input', question: t.pending.question, options: t.pending.options });
|
|
152
|
+
persistTask(t);
|
|
138
153
|
return new Promise((resolve) => { t._answer = resolve; });
|
|
139
154
|
};
|
|
140
155
|
|
|
@@ -152,6 +167,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
152
167
|
if (t._cancelling && t.status !== 'error') t.status = 'cancelled'; // 使用者中斷
|
|
153
168
|
t.finishedAt = new Date().toISOString();
|
|
154
169
|
emit(t, { type: 'end', status: t.status, result: t.result, error: t.error });
|
|
170
|
+
persistTask(t);
|
|
155
171
|
active--;
|
|
156
172
|
try { onFinish?.(t); } catch { /* webhook 錯不影響佇列 */ }
|
|
157
173
|
pump();
|
|
@@ -163,6 +179,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
163
179
|
enqueue(spec) {
|
|
164
180
|
const t = { id: newId('t'), status: 'queued', spec: spec || {}, events: [], result: null, error: null, createdAt: new Date().toISOString(), startedAt: null, finishedAt: null };
|
|
165
181
|
tasks.set(t.id, t);
|
|
182
|
+
persistTask(t);
|
|
166
183
|
queue.push(t);
|
|
167
184
|
pump();
|
|
168
185
|
return t;
|
|
@@ -180,7 +197,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
180
197
|
if (t.status === 'queued') {
|
|
181
198
|
const i = queue.indexOf(t); if (i >= 0) queue.splice(i, 1);
|
|
182
199
|
t.status = 'cancelled'; t.finishedAt = new Date().toISOString();
|
|
183
|
-
emit(t, { type: 'end', status: 'cancelled' });
|
|
200
|
+
emit(t, { type: 'end', status: 'cancelled' }); persistTask(t);
|
|
184
201
|
return true;
|
|
185
202
|
}
|
|
186
203
|
if (typeof t._answer === 'function') { const r = t._answer; t._answer = null; t.pending = null; r(''); } // 解除待答阻塞
|
|
@@ -193,7 +210,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
193
210
|
const t = tasks.get(id);
|
|
194
211
|
if (!t || typeof t._answer !== 'function') return false;
|
|
195
212
|
const resolve = t._answer; t._answer = null; t.pending = null; t.status = 'running';
|
|
196
|
-
emit(t, { type: 'answered', answer: String(text ?? '') });
|
|
213
|
+
emit(t, { type: 'answered', answer: String(text ?? '') }); persistTask(t);
|
|
197
214
|
resolve(String(text ?? ''));
|
|
198
215
|
return true;
|
|
199
216
|
},
|
|
@@ -212,9 +229,14 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
|
|
|
212
229
|
* @returns {import('node:http').Server}
|
|
213
230
|
*/
|
|
214
231
|
export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2, local = false } = {}) {
|
|
215
|
-
const sessions = new Map(); // sessionId -> {
|
|
232
|
+
const sessions = new Map(); // sessionId -> { history }
|
|
216
233
|
mkdirSync(baseDir, { recursive: true });
|
|
217
234
|
|
|
235
|
+
// 對話 session 持久化(讓「繼續/調整」跨重啟可用):啟動載回 + 每次更新落地。
|
|
236
|
+
const sessDir = join(baseDir, 'sessions');
|
|
237
|
+
try { if (existsSync(sessDir)) for (const f of readdirSync(sessDir).filter((x) => x.endsWith('.json'))) { try { sessions.set(f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(sessDir, f), 'utf8'))); } catch { /* 略 */ } } } catch { /* 略 */ }
|
|
238
|
+
const persistSession = (id, sess) => { try { mkdirSync(sessDir, { recursive: true }); writeFileSync(join(sessDir, id + '.json'), JSON.stringify({ history: sess.history })); } catch { /* 略 */ } };
|
|
239
|
+
|
|
218
240
|
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); };
|
|
219
241
|
// header bearer 為主;img/iframe/下載這類瀏覽器發起的 GET 無法帶 header,允許 ?token=(同源、PoC)
|
|
220
242
|
const authed = (req) => { if (!token) return true; if (req.headers.authorization === `Bearer ${token}`) return true; try { return new URL(req.url, 'http://x').searchParams.get('token') === token; } catch { return false; } };
|
|
@@ -251,13 +273,13 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
251
273
|
if (spec.mode === 'goal') {
|
|
252
274
|
// 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
|
|
253
275
|
const o = await kernel.runOutcome(spec.goal || spec.input || '', { history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
|
|
254
|
-
sess.history = o.history || []; sessions.set(sessionId, sess);
|
|
276
|
+
sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
|
|
255
277
|
try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
|
|
256
278
|
// 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
|
|
257
279
|
return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: o.summary || lastText(sess.history), usage, rounds: o.rounds, done: o.done, aborted: o.aborted, artifacts: o.artifacts };
|
|
258
280
|
}
|
|
259
281
|
const r = await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped, onAgent });
|
|
260
|
-
sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
|
|
282
|
+
sess.history = r.messages || r.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
|
|
261
283
|
return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
|
|
262
284
|
}
|
|
263
285
|
|
|
@@ -272,6 +294,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
272
294
|
|
|
273
295
|
const tasks = createTaskStore({
|
|
274
296
|
concurrency,
|
|
297
|
+
persistDir: join(baseDir, 'tasks'),
|
|
275
298
|
runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
|
|
276
299
|
onFinish: (task) => { log({ task: task.id, pack: task.spec.pack, mode: task.spec.mode || 'turn', status: task.status, ms: task.startedAt ? Date.parse(task.finishedAt) - Date.parse(task.startedAt) : 0 }); fireWebhook(task); },
|
|
277
300
|
});
|
package/src/app/web/index.html
CHANGED
|
@@ -315,8 +315,8 @@ $("#go").onclick = async () => {
|
|
|
315
315
|
poll();
|
|
316
316
|
};
|
|
317
317
|
|
|
318
|
-
function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled")?"error":""; }
|
|
319
|
-
function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷"})[s]||s; }
|
|
318
|
+
function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled"||s==="interrupted")?"error":""; }
|
|
319
|
+
function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷",interrupted:"已中斷(重啟)"})[s]||s; }
|
|
320
320
|
const CANCELLABLE = ["queued","running","needs-input"];
|
|
321
321
|
function todosHtml(p){ if(!p||!(p.todos||[]).length) return ""; const ic=s=>s==="completed"?"☑":s==="in_progress"?"◐":"☐"; return `<div class="todos">${p.todos.map(td=>`<div class="todo ${td.status}">${ic(td.status)} ${esc(td.content)}</div>`).join("")}</div>`; }
|
|
322
322
|
async function cancelTask(id){ await api("/v1/tasks/"+id+"/cancel",{method:"POST"}); for(let i=0;i<10;i++){ await new Promise(r=>setTimeout(r,600)); const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; renderCurrent(t); if(["done","error","cancelled"].includes(t.status)){loadHistory();break;} } }
|