@shawnstack/quickforge 1.7.10 → 1.7.11
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/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
- package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
- package/dist/assets/{CloudAccountSettingsPage-CDkKN1Sz.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
- package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-mLvwvTmL.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-C9lOTi75.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
- package/dist/assets/{ShareLinksSettingsPage-CH_t33A3.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
- package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-C7CDEoX9.js} +1 -1
- package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
- package/dist/assets/{WorkspaceInspector-DPXgLIum.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
- package/dist/assets/{index-DQBOm9gt.js → index-BgMvQW1b.js} +13 -13
- package/dist/assets/index-CB1g2D0f.css +3 -0
- package/dist/assets/local-tools-Dopjg9xq.js +270 -0
- package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
- package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-DYp35JM3.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +62 -46
- package/server/auto-archive.mjs +55 -8
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
- package/server/maintenance/export-session-state-v1.mjs +1 -11
- package/server/routes/backup.mjs +1 -1
- package/server/routes/storage.mjs +63 -49
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-state-backup.mjs +56 -5
- package/server/session-state-import.mjs +157 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +128 -210
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +337 -387
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +218 -454
- package/server/utils/logger.mjs +15 -4
- package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
- package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
- package/dist/assets/index-ps85guTs.css +0 -3
- package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
- package/server/session-state-cutover.mjs +0 -563
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{i as e,n as t}from"./rolldown-runtime-DWdDZTNf.js";import{Bt as n,Gt as r,J as i,Jt as a,b as o,n as s}from"./icons-DM_qJ7VJ.js";import{n as c}from"./react-vendor-BpWW_Nvp.js";import{An as l,Dn as u,Pn as d}from"./index-
|
|
1
|
+
import{i as e,n as t}from"./rolldown-runtime-DWdDZTNf.js";import{Bt as n,Gt as r,J as i,Jt as a,b as o,n as s}from"./icons-DM_qJ7VJ.js";import{n as c}from"./react-vendor-BpWW_Nvp.js";import{An as l,Dn as u,Pn as d}from"./index-BgMvQW1b.js";var f=t({SkillsDialog:()=>_,SkillsManagerPanel:()=>g}),p=e(a(),1),m=c();async function h(e){let t=await e.json().catch(()=>null);if(!e.ok)throw Error(t?.error||`HTTP ${e.status}`);return t}function g({active:e=!0,scope:t,project:a,embedded:c=!1,onClose:f,onSaved:g}){let[_,v]=(0,p.useState)([]),[y,b]=(0,p.useState)(()=>new Set),[x,S]=(0,p.useState)(``),[C,w]=(0,p.useState)([]),[T,E]=(0,p.useState)(!1),[D,O]=(0,p.useState)(!1),[k,A]=(0,p.useState)(``),j=t===`project`,[M,N]=(0,p.useState)(null),[P,F]=(0,p.useState)(null),[I,L]=(0,p.useState)(!1),[R,z]=(0,p.useState)(``),B=(0,p.useCallback)(()=>{N(null),F(null),z(``)},[]),V=(0,p.useCallback)(()=>{B(),f?.()},[f,B]);(0,p.useEffect)(()=>{if(!e||j&&!a)return;let t=!1;return(async()=>{E(!0),A(``);try{let e=j?`/api/skills?projectId=${encodeURIComponent(a.id)}`:`/api/skills?scope=global`,n=await h(await fetch(e));if(t)return;v(Array.isArray(n.skills)?n.skills:[]),b(new Set(Array.isArray(n.selectedSkills)?n.selectedSkills:[])),w(Array.isArray(n.searchPaths)?n.searchPaths:[])}catch(e){t||A(e instanceof Error?e.message:d(`failedToLoadSkills`))}finally{t||E(!1)}})(),()=>{t=!0}},[e,a,j]),(0,p.useEffect)(()=>{if(!e||c)return;let t=e=>{e.key===`Escape`&&(M?B():D||V())};return document.addEventListener(`keydown`,t),()=>document.removeEventListener(`keydown`,t)},[e,V,c,D,M,B]);let H=(0,p.useMemo)(()=>{let e=x.trim().toLowerCase();return e?_.filter(t=>[t.name,t.displayName,t.description,t.source,t.compatibility,t.allowedTools,...t.tags??[],...t.triggers??[]].filter(Boolean).join(` `).toLowerCase().includes(e)):_},[x,_]),U=async e=>{N(e),F(null),z(``),L(!0);try{let t=new URLSearchParams({name:e});j&&a?(t.set(`scope`,`project`),t.set(`projectId`,a.id)):t.set(`scope`,`global`),F(await h(await fetch(`/api/skills/content?${t}`)))}catch(e){z(e instanceof Error?e.message:d(`failedToReadSkill`))}finally{L(!1)}};if(!e||j&&!a)return null;let W=e=>{if(D)return;let t=new Set(y);t.has(e)?t.delete(e):t.add(e),b(t),G(t)},G=async e=>{O(!0),A(``);try{let n=await h(await fetch(j?`/api/skills/project`:`/api/skills/global`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(j?{projectId:a.id,selectedSkills:[...e]}:{selectedSkills:[...e]})}));g({scope:t,project:j?n.projects?.find(e=>e.id===a.id)??{...a,skills:n.selectedSkills}:void 0,projects:n.projects,selectedSkills:n.selectedSkills})}catch(e){b(y),A(e instanceof Error?e.message:d(`failedToSaveSkills`))}finally{O(!1)}},K=d(j?`projectSkills`:`globalSkills`),q=j?d(`projectSkillsDescription`,{project:a.name}):d(`globalSkillsDescription`),J=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(`div`,{className:`quickforge-settings-divider p-3`,children:(0,m.jsxs)(`div`,{className:`quickforge-settings-inline-field`,children:[(0,m.jsx)(o,{className:`pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground/60`}),(0,m.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),placeholder:d(`searchSkills`),className:`quickforge-settings-input`,disabled:T||D})]})}),k?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:k}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsx)(`span`,{className:`quickforge-settings-row-title`,children:d(`availableSkills`)}),(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-info`,children:d(`selectedSkillsCount`,{count:y.size})})]}),(0,m.jsx)(`div`,{className:c?``:`max-h-[46vh] overflow-y-auto`,children:T?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):H.length===0?(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noMatchingSkills`)}):H.map(e=>{let t=y.has(e.name);return(0,m.jsxs)(`div`,{className:l(`quickforge-settings-list-item`,t&&`bg-muted/12`),children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-main`,children:[(0,m.jsx)(`div`,{className:`quickforge-settings-row-title`,children:e.displayName||e.name}),e.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description`,children:e.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta`,children:[e.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.source}):null,e.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.compatibility}):null,e.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e.allowedTools}):null,e.tags?.slice(0,5).map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]})]}),(0,m.jsxs)(`div`,{className:`quickforge-settings-list-item-actions`,onClick:e=>e.stopPropagation(),children:[(0,m.jsxs)(`label`,{className:`quickforge-settings-switch`,"aria-disabled":D?`true`:`false`,children:[(0,m.jsx)(`input`,{type:`checkbox`,checked:t,disabled:D,onChange:()=>W(e.name),"aria-label":t?`Disable ${e.name}`:`Enable ${e.name}`}),(0,m.jsx)(`span`,{"aria-hidden":`true`})]}),(0,m.jsx)(`button`,{type:`button`,className:`quickforge-settings-icon-action`,onClick:()=>void U(e.name),disabled:D||I,title:d(`readSkill`),"aria-label":`${d(`readSkill`)}: ${e.displayName||e.name}`,children:(0,m.jsx)(n,{className:`size-4`})})]})]},e.name)})})]}),Y=(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsxs)(`button`,{type:`button`,className:`quickforge-settings-button quickforge-settings-button-secondary`,onClick:B,disabled:I,children:[(0,m.jsx)(r,{className:`mr-2 size-4`}),d(`backToSkillList`)]}),(0,m.jsx)(`div`,{className:`quickforge-settings-row-main min-w-0`,children:(0,m.jsx)(`div`,{className:`quickforge-settings-row-title truncate`,children:P?.displayName||P?.name||M})})]}),(0,m.jsx)(`div`,{className:`p-4`,children:I?(0,m.jsxs)(`div`,{className:`quickforge-settings-empty-row inline-flex items-center gap-2`,children:[(0,m.jsx)(i,{className:`size-4 animate-spin`}),d(`loading`)]}):R?(0,m.jsx)(`div`,{className:`quickforge-settings-alert quickforge-settings-warning-attached`,children:R}):P?(0,m.jsxs)(m.Fragment,{children:[P.description?(0,m.jsx)(`div`,{className:`quickforge-settings-row-description mb-3`,children:P.description}):null,(0,m.jsxs)(`div`,{className:`quickforge-settings-meta mb-3`,children:[P.version?(0,m.jsxs)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:[`v`,P.version]}):null,P.source?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.source}):null,P.compatibility?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.compatibility}):null,P.allowedTools?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.allowedTools}):null,P.license?(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:P.license}):null,P.tags?.map(e=>(0,m.jsx)(`span`,{className:`quickforge-settings-badge quickforge-settings-badge-muted`,children:e},e))]}),P.triggers?.length?(0,m.jsxs)(`div`,{className:`quickforge-settings-row-description mb-3`,children:[`Triggers: `,P.triggers.join(`, `)]}):null,P.instructions?(0,m.jsx)(`pre`,{className:`whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 px-4 py-3 font-mono text-sm leading-6 text-foreground/80`,children:P.instructions}):(0,m.jsx)(`div`,{className:`quickforge-settings-empty-row`,children:d(`noSkillContent`)})]}):null})]});return c?(0,m.jsx)(`div`,{className:`quickforge-settings-stack`,children:(0,m.jsx)(`section`,{className:`quickforge-settings-section`,"aria-label":K,children:M?Y:(0,m.jsxs)(m.Fragment,{children:[C.length?(0,m.jsx)(`div`,{className:`px-5 pt-4`,children:(0,m.jsxs)(`span`,{className:`quickforge-settings-row-description inline-flex items-center`,children:[d(`skillSearchPaths`),(0,m.jsx)(u,{label:C.join(`
|
|
2
2
|
`)})]})}):null,J]})})}):(0,m.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4`,onClick:e=>{e.target===e.currentTarget&&!D&&V()},children:(0,m.jsxs)(`div`,{className:`flex max-h-[85vh] w-full max-w-2xl flex-col rounded-lg border border-border bg-background shadow-quickforge`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-toolbar`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-row-main`,children:[(0,m.jsxs)(`div`,{className:`quickforge-settings-row-title`,children:[K,(0,m.jsx)(u,{label:q})]}),(0,m.jsx)(`div`,{className:`quickforge-settings-row-description`,children:q}),C.length?(0,m.jsxs)(`div`,{className:`quickforge-settings-row-description inline-flex items-center`,children:[d(`skillSearchPaths`),(0,m.jsx)(u,{label:C.join(`
|
|
3
3
|
`)})]}):null]}),(0,m.jsx)(`button`,{type:`button`,className:`quickforge-settings-icon-action`,onClick:V,disabled:D,"aria-label":d(`close`),children:(0,m.jsx)(s,{className:`size-4`})})]}),(0,m.jsx)(`div`,{className:`min-h-0 flex-1 overflow-y-auto`,children:M?Y:J})]})})}function _({open:e,scope:t,project:n,onOpenChange:r,onSaved:i}){return(0,m.jsx)(g,{active:e,scope:t,project:n,onClose:()=>r(!1),onSaved:i})}export{f as n,_ as t};
|
package/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="QuickForge" />
|
|
12
12
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
13
13
|
<title>速构 QuickForge</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-BgMvQW1b.js"></script>
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-DWdDZTNf.js">
|
|
16
16
|
<link rel="modulepreload" crossorigin href="/assets/pi-ai-Cr2ee4a0.js">
|
|
17
17
|
<link rel="modulepreload" crossorigin href="/assets/dist-yDHbi5ux.js">
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
<link rel="modulepreload" crossorigin href="/assets/react-vendor-BpWW_Nvp.js">
|
|
23
23
|
<link rel="modulepreload" crossorigin href="/assets/logger-8REuLPGK.js">
|
|
24
24
|
<link rel="modulepreload" crossorigin href="/assets/model-display-label-CURwH0xM.js">
|
|
25
|
-
<link rel="modulepreload" crossorigin href="/assets/index-
|
|
26
|
-
<link rel="modulepreload" crossorigin href="/assets/skills-dialog-
|
|
27
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
25
|
+
<link rel="modulepreload" crossorigin href="/assets/index-BgMvQW1b.js">
|
|
26
|
+
<link rel="modulepreload" crossorigin href="/assets/skills-dialog-DYp35JM3.js">
|
|
27
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CB1g2D0f.css">
|
|
28
28
|
</head>
|
|
29
29
|
<body>
|
|
30
30
|
<div id="root"></div>
|
package/package.json
CHANGED
package/server/acp/server.mjs
CHANGED
|
@@ -922,16 +922,13 @@ export async function runQuickForgeAcpStdio() {
|
|
|
922
922
|
console.log = (...args) => console.error(...args)
|
|
923
923
|
let sqliteStorage
|
|
924
924
|
try {
|
|
925
|
-
const [
|
|
925
|
+
const [storageModule, sqliteModule, sessionIndexModule] = await Promise.all([
|
|
926
926
|
import('../storage.mjs'),
|
|
927
927
|
import('../sqlite/database.mjs'),
|
|
928
928
|
import('../session-index-service.mjs'),
|
|
929
929
|
])
|
|
930
|
-
|
|
931
|
-
await
|
|
932
|
-
await sqliteModule.initializeSqliteStorage()
|
|
933
|
-
sessionIndexModule.configureSessionIndex({ readBuckets: readPhysicalSessionMetadataBuckets })
|
|
934
|
-
registerSessionMetadataCommitHook(sessionIndexModule.syncSessionMetadataCommit)
|
|
930
|
+
await storageModule.ensureStorage()
|
|
931
|
+
sqliteStorage = await sqliteModule.initializeSqliteStorage()
|
|
935
932
|
await sessionIndexModule.initializeSessionIndex()
|
|
936
933
|
const quickForgeAgent = await createQuickForgeAcpAgent()
|
|
937
934
|
const stream = ndJsonStream(
|
package/server/agent-manager.mjs
CHANGED
|
@@ -28,8 +28,8 @@ import {
|
|
|
28
28
|
validateModelReference,
|
|
29
29
|
} from './agent-profile-schema.mjs'
|
|
30
30
|
import { projectContextFromId, defaultGlobalWorkspaceContext, readProjectConfig } from './project-config.mjs'
|
|
31
|
-
import { ensureStorage, readStore, atomicUpdate,
|
|
32
|
-
import {
|
|
31
|
+
import { ensureStorage, readStore, atomicUpdate, readSessionValue, tempAgentsDir } from './storage.mjs'
|
|
32
|
+
import { readSessionStateRecord, saveSessionStatePair, deleteSessionState, storedMessagesState, sessionMessagesTailDigest } from './session-state-service.mjs'
|
|
33
33
|
import { logger } from './utils/logger.mjs'
|
|
34
34
|
import { publishChannelSessionChanged } from './channels/event-relay.mjs'
|
|
35
35
|
import { withSessionPersistenceLock } from './session-persistence-lock.mjs'
|
|
@@ -1967,6 +1967,10 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
1967
1967
|
persistedMessageCount: Number.isInteger(config.persistedMessageCount) && config.persistedMessageCount >= 0 ? config.persistedMessageCount : null,
|
|
1968
1968
|
persistedTailDigest: typeof config.persistedTailDigest === 'string' ? config.persistedTailDigest : null,
|
|
1969
1969
|
persistConflictCount: 0,
|
|
1970
|
+
// Set when an authoritative persist is skipped after CAS conflicts; cleared
|
|
1971
|
+
// on the next successful persist. Surfaced via getSessionState/getSessionStatus
|
|
1972
|
+
// so the UI can warn the user instead of silently losing messages.
|
|
1973
|
+
persistDegraded: null,
|
|
1970
1974
|
lastAutoCompactAt: null,
|
|
1971
1975
|
lastAutoCompactRejected: null,
|
|
1972
1976
|
memoryEnabled: initialMemoryEnabled,
|
|
@@ -2153,6 +2157,26 @@ function canonicalStateJson(value) {
|
|
|
2153
2157
|
return JSON.stringify(value)
|
|
2154
2158
|
}
|
|
2155
2159
|
|
|
2160
|
+
/**
|
|
2161
|
+
* Mark the session as persist-degraded (an authoritative persist was skipped
|
|
2162
|
+
* after CAS conflicts) and notify connected clients so the UI can surface a
|
|
2163
|
+
* non-blocking warning instead of silently losing messages.
|
|
2164
|
+
*/
|
|
2165
|
+
function markPersistDegraded(session, attempts) {
|
|
2166
|
+
session.persistDegraded = { at: new Date().toISOString(), attempts }
|
|
2167
|
+
emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: true })
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Clear the degraded marker after a successful persist and notify clients so
|
|
2172
|
+
* the UI warning disappears.
|
|
2173
|
+
*/
|
|
2174
|
+
function clearPersistDegraded(session) {
|
|
2175
|
+
if (!session.persistDegraded) return
|
|
2176
|
+
session.persistDegraded = null
|
|
2177
|
+
emitSessionEvent(session, { type: 'persist_degraded', persistDegraded: false })
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2156
2180
|
/**
|
|
2157
2181
|
* Persist a full session record in one authoritative SQLite transaction with
|
|
2158
2182
|
* revision CAS. When the CAS fails because a concurrent metadata-only change
|
|
@@ -2201,6 +2225,7 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
|
|
|
2201
2225
|
session.persistedMessageCount = Array.isArray(sessionData.messages) ? sessionData.messages.length : 0
|
|
2202
2226
|
}
|
|
2203
2227
|
session.persistedTailDigest = sessionMessagesTailDigest(sessionData.messages)
|
|
2228
|
+
clearPersistDegraded(session)
|
|
2204
2229
|
return { ...saved, metadata: mergedMetadata }
|
|
2205
2230
|
} catch (error) {
|
|
2206
2231
|
if (error?.errorCode !== 'SESSION_STATE_CONFLICT') throw error
|
|
@@ -2222,11 +2247,13 @@ async function persistAuthoritativeSessionState(session, sessionData, metadata)
|
|
|
2222
2247
|
}
|
|
2223
2248
|
session.persistConflictCount += 1
|
|
2224
2249
|
logger.warn(`Session ${sessionId} persist conflict: storage changed agent-owned fields; skipping overwrite`, { sessionId })
|
|
2250
|
+
markPersistDegraded(session, attempt)
|
|
2225
2251
|
return null
|
|
2226
2252
|
}
|
|
2227
2253
|
}
|
|
2228
2254
|
session.persistConflictCount += 1
|
|
2229
2255
|
logger.warn(`Session ${sessionId} persist still conflicting after ${maxAttempts} merge retries`, { sessionId, reason: lastConflict?.message })
|
|
2256
|
+
markPersistDegraded(session, maxAttempts)
|
|
2230
2257
|
return null
|
|
2231
2258
|
}
|
|
2232
2259
|
|
|
@@ -2239,14 +2266,7 @@ async function persistSessionUnlocked(session) {
|
|
|
2239
2266
|
|
|
2240
2267
|
if (messages.length === 0) {
|
|
2241
2268
|
try {
|
|
2242
|
-
|
|
2243
|
-
else {
|
|
2244
|
-
await deleteSessionValue(sessionId)
|
|
2245
|
-
await atomicSessionMetadataUpdate(scope, projectId, (data) => {
|
|
2246
|
-
delete data[sessionId]
|
|
2247
|
-
return data
|
|
2248
|
-
})
|
|
2249
|
-
}
|
|
2269
|
+
deleteSessionState(sessionId, { expectedRevision: session.persistedStorageRevision })
|
|
2250
2270
|
session.persistedStorageRevision = null
|
|
2251
2271
|
session.persistedStateVersion = null
|
|
2252
2272
|
session.persistedStateJson = null
|
|
@@ -2353,34 +2373,14 @@ async function persistSessionUnlocked(session) {
|
|
|
2353
2373
|
idleRetention: session.idleRetention || undefined,
|
|
2354
2374
|
}
|
|
2355
2375
|
|
|
2356
|
-
// Write body + metadata as one authoritative SQLite transaction
|
|
2376
|
+
// Write body + metadata as one authoritative SQLite transaction.
|
|
2357
2377
|
let persistedMetadata
|
|
2358
2378
|
try {
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
persistedMetadata = saved.metadata
|
|
2365
|
-
} else {
|
|
2366
|
-
await writeSessionValue(sessionId, sessionData)
|
|
2367
|
-
await atomicSessionMetadataUpdate(scope, projectId, (data) => {
|
|
2368
|
-
const existingMetadata = data[sessionId]
|
|
2369
|
-
const archivedAt = existingMetadata?.archivedAt
|
|
2370
|
-
// Merge onto the existing record so storage-only fields the in-memory
|
|
2371
|
-
// session does not own (pinnedAt, archivedAt, …) survive the rebuild.
|
|
2372
|
-
persistedMetadata = {
|
|
2373
|
-
...existingMetadata,
|
|
2374
|
-
...metadata,
|
|
2375
|
-
...(archivedAt ? { archivedAt } : {}),
|
|
2376
|
-
}
|
|
2377
|
-
data[sessionId] = persistedMetadata
|
|
2378
|
-
return data
|
|
2379
|
-
})
|
|
2380
|
-
if (persistedMetadata?.archivedAt) {
|
|
2381
|
-
await writeSessionValue(sessionId, { ...sessionData, archivedAt: persistedMetadata.archivedAt })
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2379
|
+
const saved = await persistAuthoritativeSessionState(session, sessionData, metadata)
|
|
2380
|
+
if (!saved) return null
|
|
2381
|
+
session.persistedStorageRevision = saved.revision
|
|
2382
|
+
session.persistedStateVersion = saved.stateVersion
|
|
2383
|
+
persistedMetadata = saved.metadata
|
|
2384
2384
|
} catch (err) {
|
|
2385
2385
|
logger.error(`Failed to persist session ${sessionId}:`, err, { sessionId })
|
|
2386
2386
|
return null
|
|
@@ -2410,11 +2410,10 @@ const SLOW_PERSIST_LOG_MS = 1_000
|
|
|
2410
2410
|
|
|
2411
2411
|
async function persistSession(session) {
|
|
2412
2412
|
const startedAt = performance.now()
|
|
2413
|
-
// Serialize per session
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
|
|
2417
|
-
const lockKey = isSessionStateAuthoritative() ? `session:${session.sessionId}` : ''
|
|
2413
|
+
// Serialize per session (per-row revision CAS guarantees correctness);
|
|
2414
|
+
// cross-session persists run independently instead of piling onto one
|
|
2415
|
+
// global chain that also gates destroyAgent.
|
|
2416
|
+
const lockKey = `session:${session.sessionId}`
|
|
2418
2417
|
const result = await withSessionPersistenceLock(() => persistSessionUnlocked(session), lockKey)
|
|
2419
2418
|
const durationMs = performance.now() - startedAt
|
|
2420
2419
|
if (durationMs >= SLOW_PERSIST_LOG_MS) {
|
|
@@ -2976,6 +2975,7 @@ export function getSessionState(sessionId) {
|
|
|
2976
2975
|
acpSession: session.harness === AGENT_HARNESS_OPENCODE ? session.agent.state.acpSession : undefined,
|
|
2977
2976
|
isStreaming: session.abortPending ? false : session.agent.state.isStreaming,
|
|
2978
2977
|
errorMessage: session.agent.state.errorMessage,
|
|
2978
|
+
persistDegraded: session.persistDegraded ? true : undefined,
|
|
2979
2979
|
}
|
|
2980
2980
|
}
|
|
2981
2981
|
|
|
@@ -3008,6 +3008,7 @@ export function getSessionStatus(sessionId) {
|
|
|
3008
3008
|
errorMessage: session.agent.state.errorMessage,
|
|
3009
3009
|
messageCount: messages.length,
|
|
3010
3010
|
lastMessageTimestamp: lastMessage?.timestamp ?? null,
|
|
3011
|
+
persistDegraded: session.persistDegraded ? true : undefined,
|
|
3011
3012
|
}
|
|
3012
3013
|
}
|
|
3013
3014
|
|
|
@@ -3039,6 +3040,21 @@ export function releaseSse(sessionId) {
|
|
|
3039
3040
|
if (session) session.sseConnected = false
|
|
3040
3041
|
}
|
|
3041
3042
|
|
|
3043
|
+
/**
|
|
3044
|
+
* Count sessions with a live SSE connection — the background-migration idle
|
|
3045
|
+
* signal's "no active stream" input
|
|
3046
|
+
* (docs/architecture/session-storage-background-migration-design.zh-CN.md
|
|
3047
|
+
* §3.3). Derived from the per-session sseConnected flags the SSE route
|
|
3048
|
+
* already maintains, so no extra bookkeeping is required.
|
|
3049
|
+
*/
|
|
3050
|
+
export function countActiveSseStreams() {
|
|
3051
|
+
let count = 0
|
|
3052
|
+
for (const session of agentSessions.values()) {
|
|
3053
|
+
if (session.sseConnected) count += 1
|
|
3054
|
+
}
|
|
3055
|
+
return count
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3042
3058
|
/**
|
|
3043
3059
|
* Get the event bus for a session (for SSE connections).
|
|
3044
3060
|
*/
|
|
@@ -3128,8 +3144,8 @@ async function restoreAgentUnlocked(sessionId) {
|
|
|
3128
3144
|
// Read the authoritative storage record once: the body (split marker),
|
|
3129
3145
|
// revision for CAS, and the stored message rows (count + tail digest) used
|
|
3130
3146
|
// for split-representation conflict detection after restore.
|
|
3131
|
-
const record =
|
|
3132
|
-
const storedMessages =
|
|
3147
|
+
const record = readSessionStateRecord(sessionId)
|
|
3148
|
+
const storedMessages = record?.state?.messageStorage === 'split'
|
|
3133
3149
|
? storedMessagesState(sessionId)
|
|
3134
3150
|
: null
|
|
3135
3151
|
|
|
@@ -3156,9 +3172,9 @@ async function restoreAgentUnlocked(sessionId) {
|
|
|
3156
3172
|
openCodeUsage: sessionData.openCodeUsage || null,
|
|
3157
3173
|
idleRetention: sessionData.idleRetention || null,
|
|
3158
3174
|
stateVersion: sessionData.stateVersion,
|
|
3159
|
-
persistedStateVersion:
|
|
3160
|
-
persistedStorageRevision:
|
|
3161
|
-
persistedStateJson:
|
|
3175
|
+
persistedStateVersion: sessionData.stateVersion,
|
|
3176
|
+
persistedStorageRevision: record?.revision ?? null,
|
|
3177
|
+
persistedStateJson: canonicalStateJson(stripStorageOwnedStateFields(record?.state || {})),
|
|
3162
3178
|
persistedMessageStorage: record?.state?.messageStorage === 'split' ? 'split' : null,
|
|
3163
3179
|
persistedMessageCount: storedMessages?.count ?? (Array.isArray(sessionData.messages) ? sessionData.messages.length : 0),
|
|
3164
3180
|
persistedTailDigest: storedMessages?.tailDigest || sessionMessagesTailDigest(sessionData.messages),
|
package/server/auto-archive.mjs
CHANGED
|
@@ -7,10 +7,20 @@ const defaultStorage = { atomicSessionValueUpdate, atomicUpdate, atomicSessionRe
|
|
|
7
7
|
export const AUTO_ARCHIVE_SETTINGS_KEY = 'auto-archive-settings'
|
|
8
8
|
export const AUTO_ARCHIVE_AFTER_MS = 30 * 24 * 60 * 60 * 1000
|
|
9
9
|
const AUTO_ARCHIVE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
|
10
|
+
export const AUTO_ARCHIVE_INITIAL_DELAY_MS = 30_000
|
|
10
11
|
|
|
11
12
|
let autoArchiveTimer = null
|
|
13
|
+
let autoArchiveInitialTimer = null
|
|
12
14
|
let autoArchiveRunning = false
|
|
13
15
|
|
|
16
|
+
// Yield one macrotask turn between heavy archive commits: each archive is a
|
|
17
|
+
// synchronous SQLite transaction (or JSON read-modify-write) that blocks the
|
|
18
|
+
// thread while it runs, and without yields a large batch of back-to-back
|
|
19
|
+
// archives starves every pending HTTP request for the whole batch.
|
|
20
|
+
function yieldToEventLoop() {
|
|
21
|
+
return new Promise((resolve) => setImmediate(resolve))
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export function normalizeAutoArchiveSettings(value) {
|
|
15
25
|
if (!value || typeof value !== 'object') return { enabled: false }
|
|
16
26
|
return { enabled: value.enabled === true }
|
|
@@ -65,8 +75,25 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
|
|
|
65
75
|
const metadataStore = await storage.readStore('sessions-metadata')
|
|
66
76
|
const candidates = []
|
|
67
77
|
|
|
78
|
+
// Metadata-first scan: metadata alone decides activeness whenever it carries
|
|
79
|
+
// any timestamp, so the common case never loads the session body (loading and
|
|
80
|
+
// JSON.parsing every 2.9GB-library body is what stalled the event loop for
|
|
81
|
+
// ~minutes on startup). The body is only read when metadata has no usable
|
|
82
|
+
// timestamp at all. The scan is therefore deliberately conservative — a body
|
|
83
|
+
// newer than its (stale) metadata is treated as a candidate — which is safe:
|
|
84
|
+
// a wrongly suspected candidate is rejected by the full-state re-check inside
|
|
85
|
+
// the archive transactions below, and a missed candidate is simply picked up
|
|
86
|
+
// by the next scan. persistSession refreshes metadata timestamps on every
|
|
87
|
+
// save, so active sessions always stay fresh in metadata.
|
|
68
88
|
for (const [sessionId, metadata] of Object.entries(metadataStore)) {
|
|
69
89
|
if (!metadata || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running') continue
|
|
90
|
+
const metadataActivityTime = sessionActivityTime(metadata)
|
|
91
|
+
if (metadataActivityTime !== null) {
|
|
92
|
+
if (metadataActivityTime >= cutoff) continue
|
|
93
|
+
candidates.push(sessionId)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
// Metadata carries no timestamp at all: fall back to the body.
|
|
70
97
|
const session = await storage.readSessionValue(sessionId)
|
|
71
98
|
if (!session || session.archivedAt) continue
|
|
72
99
|
const activityTime = sessionActivityTime(metadata, session)
|
|
@@ -75,10 +102,13 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
|
|
|
75
102
|
}
|
|
76
103
|
|
|
77
104
|
const archivedSessionIds = []
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const sessionId of candidates) {
|
|
105
|
+
// Authoritative: archive body + metadata in one SQLite transaction (CAS
|
|
106
|
+
// retried on concurrent metadata-only changes), so no half-archived state.
|
|
107
|
+
if (typeof storage.atomicSessionRecordUpdate === 'function') {
|
|
108
|
+
for (const [index, sessionId] of candidates.entries()) {
|
|
109
|
+
// Let pending requests run between archive transactions (not before the
|
|
110
|
+
// first candidate — nothing to yield from yet).
|
|
111
|
+
if (index > 0) await yieldToEventLoop()
|
|
82
112
|
const updated = await storage.atomicSessionRecordUpdate(sessionId, ({ state, metadata }) => {
|
|
83
113
|
const activityTime = sessionActivityTime(metadata, state)
|
|
84
114
|
if (state.archivedAt || metadata.archivedAt || metadata.messageCount === 0 || metadata.taskStatus === 'running' || activityTime === null || activityTime >= cutoff) return null
|
|
@@ -87,7 +117,10 @@ async function archiveInactiveSessionsUnlocked(options = {}) {
|
|
|
87
117
|
if (updated?.state?.archivedAt === archivedAt) archivedSessionIds.push(sessionId)
|
|
88
118
|
}
|
|
89
119
|
} else {
|
|
90
|
-
for (const sessionId of candidates) {
|
|
120
|
+
for (const [index, sessionId] of candidates.entries()) {
|
|
121
|
+
// Let pending requests run between archive transactions (not before the
|
|
122
|
+
// first candidate — nothing to yield from yet).
|
|
123
|
+
if (index > 0) await yieldToEventLoop()
|
|
91
124
|
const updatedSession = await storage.atomicSessionValueUpdate(sessionId, (session) => {
|
|
92
125
|
const activityTime = sessionActivityTime(session)
|
|
93
126
|
if (session.archivedAt || activityTime === null || activityTime >= cutoff) return session
|
|
@@ -134,16 +167,30 @@ export async function archiveInactiveSessions(options = {}) {
|
|
|
134
167
|
}
|
|
135
168
|
}
|
|
136
169
|
|
|
137
|
-
|
|
170
|
+
// `options` exists for tests (inject storage/now like archiveInactiveSessions);
|
|
171
|
+
// production callers pass nothing and get defaultStorage + Date.now().
|
|
172
|
+
export function startAutoArchiveRunner(options = {}) {
|
|
138
173
|
if (autoArchiveTimer) return
|
|
139
174
|
autoArchiveTimer = setInterval(() => {
|
|
140
|
-
archiveInactiveSessions().catch((error) => logger.error('Automatic conversation archive failed:', error))
|
|
175
|
+
archiveInactiveSessions(options).catch((error) => logger.error('Automatic conversation archive failed:', error))
|
|
141
176
|
}, AUTO_ARCHIVE_CHECK_INTERVAL_MS)
|
|
142
177
|
autoArchiveTimer.unref?.()
|
|
143
|
-
|
|
178
|
+
// Delay the first pass off the startup path: right after listen() the server
|
|
179
|
+
// is absorbing the browser's initial request burst, and the scan + archive
|
|
180
|
+
// transactions on a large library can block the event loop for a long
|
|
181
|
+
// stretch. The timer is unref'd so it never keeps the process alive.
|
|
182
|
+
autoArchiveInitialTimer = setTimeout(() => {
|
|
183
|
+
autoArchiveInitialTimer = null
|
|
184
|
+
archiveInactiveSessions(options).catch((error) => logger.error('Initial automatic conversation archive failed:', error))
|
|
185
|
+
}, AUTO_ARCHIVE_INITIAL_DELAY_MS)
|
|
186
|
+
autoArchiveInitialTimer.unref?.()
|
|
144
187
|
}
|
|
145
188
|
|
|
146
189
|
export function stopAutoArchiveRunner() {
|
|
190
|
+
if (autoArchiveInitialTimer) {
|
|
191
|
+
clearTimeout(autoArchiveInitialTimer)
|
|
192
|
+
autoArchiveInitialTimer = null
|
|
193
|
+
}
|
|
147
194
|
if (!autoArchiveTimer) return
|
|
148
195
|
clearInterval(autoArchiveTimer)
|
|
149
196
|
autoArchiveTimer = null
|