@@ -3,16 +3,26 @@ import fs from "node:fs";
33import os from "node:os" ;
44import path from "node:path" ;
55import { afterEach , describe , expect , it } from "vitest" ;
6+ import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js" ;
7+ import {
8+ closeOpenClawStateDatabaseForTest ,
9+ openOpenClawStateDatabase ,
10+ } from "../state/openclaw-state-db.js" ;
11+ import {
12+ executeSqliteQuerySync ,
13+ executeSqliteQueryTakeFirstSync ,
14+ getNodeSqliteKysely ,
15+ } from "./kysely-sync.js" ;
616import {
717 formatGatewayRestartHandoffDiagnostic ,
8- GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME ,
918 GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND ,
1019 readGatewayRestartHandoffSync ,
1120 writeGatewayRestartHandoffSync ,
1221} from "./restart-handoff.js" ;
1322import type { GatewayRestartHandoff } from "./restart-handoff.js" ;
1423
1524const tempDirs : string [ ] = [ ] ;
25+ type GatewayRestartHandoffDatabase = Pick < OpenClawStateKyselyDatabase , "gateway_restart_handoff" > ;
1626
1727function createHandoffEnv ( ) : NodeJS . ProcessEnv {
1828 const dir = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "openclaw-restart-handoff-" ) ) ;
@@ -23,8 +33,77 @@ function createHandoffEnv(): NodeJS.ProcessEnv {
2333 } ;
2434}
2535
26- function handoffPath ( env : NodeJS . ProcessEnv ) : string {
27- return path . join ( env . OPENCLAW_STATE_DIR ?? "" , GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME ) ;
36+ function legacyHandoffPath ( env : NodeJS . ProcessEnv ) : string {
37+ return path . join ( env . OPENCLAW_STATE_DIR ?? "" , "gateway-supervisor-restart-handoff.json" ) ;
38+ }
39+
40+ function readHandoffRow ( env : NodeJS . ProcessEnv ) {
41+ const { db } = openOpenClawStateDatabase ( { env } ) ;
42+ const stateDb = getNodeSqliteKysely < GatewayRestartHandoffDatabase > ( db ) ;
43+ return executeSqliteQueryTakeFirstSync (
44+ db ,
45+ stateDb
46+ . selectFrom ( "gateway_restart_handoff" )
47+ . select ( [
48+ "handoff_key" ,
49+ "kind" ,
50+ "version" ,
51+ "intent_id" ,
52+ "pid" ,
53+ "process_instance_id" ,
54+ "created_at" ,
55+ "expires_at" ,
56+ "reason" ,
57+ "restart_trace_started_at" ,
58+ "restart_trace_last_at" ,
59+ "source" ,
60+ "restart_kind" ,
61+ "supervisor_mode" ,
62+ ] )
63+ . where ( "handoff_key" , "=" , "current" ) ,
64+ ) ;
65+ }
66+
67+ function insertHandoffRow (
68+ env : NodeJS . ProcessEnv ,
69+ values : {
70+ kind ?: string ;
71+ version ?: number ;
72+ intentId ?: string ;
73+ pid ?: number ;
74+ createdAt ?: number ;
75+ expiresAt ?: number ;
76+ reason ?: string | null ;
77+ source ?: string ;
78+ restartKind ?: string ;
79+ supervisorMode ?: string ;
80+ restartTraceStartedAt ?: number | null ;
81+ restartTraceLastAt ?: number | null ;
82+ } ,
83+ ) {
84+ const { db } = openOpenClawStateDatabase ( { env } ) ;
85+ const stateDb = getNodeSqliteKysely < GatewayRestartHandoffDatabase > ( db ) ;
86+ const now = Date . now ( ) ;
87+ executeSqliteQuerySync (
88+ db ,
89+ stateDb . insertInto ( "gateway_restart_handoff" ) . values ( {
90+ handoff_key : "current" ,
91+ kind : values . kind ?? GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND ,
92+ version : values . version ?? 1 ,
93+ intent_id : values . intentId ?? "intent-1" ,
94+ pid : values . pid ?? 111 ,
95+ process_instance_id : null ,
96+ created_at : values . createdAt ?? 1_000 ,
97+ expires_at : values . expiresAt ?? 61_000 ,
98+ reason : values . reason ?? null ,
99+ restart_trace_started_at : values . restartTraceStartedAt ?? null ,
100+ restart_trace_last_at : values . restartTraceLastAt ?? null ,
101+ source : values . source ?? "plugin-change" ,
102+ restart_kind : values . restartKind ?? "full-process" ,
103+ supervisor_mode : values . supervisorMode ?? "external" ,
104+ updated_at_ms : now ,
105+ } ) ,
106+ ) ;
28107}
29108
30109function expectWrittenHandoff (
@@ -39,6 +118,7 @@ function expectWrittenHandoff(
39118
40119describe ( "gateway restart handoff" , ( ) => {
41120 afterEach ( ( ) => {
121+ closeOpenClawStateDatabaseForTest ( ) ;
42122 for ( const dir of tempDirs . splice ( 0 ) ) {
43123 fs . rmSync ( dir , { force : true , recursive : true } ) ;
44124 }
@@ -67,7 +147,16 @@ describe("gateway restart handoff", () => {
67147 expect ( handoff . supervisorMode ) . toBe ( "launchd" ) ;
68148 expect ( handoff . createdAt ) . toBe ( 1_000 ) ;
69149 expect ( handoff . expiresAt ) . toBe ( 61_000 ) ;
70- expect ( fs . statSync ( handoffPath ( env ) ) . mode & 0o777 ) . toBe ( 0o600 ) ;
150+ expect ( readHandoffRow ( env ) ) . toMatchObject ( {
151+ handoff_key : "current" ,
152+ kind : GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND ,
153+ pid : 12_345 ,
154+ reason : "plugin source changed" ,
155+ source : "plugin-change" ,
156+ restart_kind : "full-process" ,
157+ supervisor_mode : "launchd" ,
158+ } ) ;
159+ expect ( fs . existsSync ( legacyHandoffPath ( env ) ) ) . toBe ( false ) ;
71160 const persisted = readGatewayRestartHandoffSync ( env , 1_500 ) ;
72161 expect ( persisted ?. pid ) . toBe ( 12_345 ) ;
73162 expect ( persisted ?. reason ) . toBe ( "plugin source changed" ) ;
@@ -126,27 +215,12 @@ describe("gateway restart handoff", () => {
126215 it ( "rejects malformed handoff payloads" , ( ) => {
127216 const env = createHandoffEnv ( ) ;
128217
129- fs . writeFileSync (
130- handoffPath ( env ) ,
131- `${ JSON . stringify ( {
132- kind : GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND ,
133- version : 1 ,
134- intentId : "bad" ,
135- pid : 111 ,
136- createdAt : 1_000 ,
137- expiresAt : 61_000 ,
138- reason : 123 ,
139- source : "bad-source" ,
140- restartKind : "full-process" ,
141- supervisorMode : "external" ,
142- } ) } \n`,
143- { encoding : "utf8" , mode : 0o600 } ,
144- ) ;
218+ insertHandoffRow ( env , { intentId : "bad" , source : "bad-source" } ) ;
145219
146220 expect ( readGatewayRestartHandoffSync ( env , 1_001 ) ) . toBeNull ( ) ;
147221 } ) ;
148222
149- it ( "rejects expired and oversized handoff files " , ( ) => {
223+ it ( "rejects expired handoff rows " , ( ) => {
150224 const env = createHandoffEnv ( ) ;
151225
152226 expectWrittenHandoff ( {
@@ -158,53 +232,43 @@ describe("gateway restart handoff", () => {
158232 ttlMs : 1_000 ,
159233 } ) ;
160234 expect ( readGatewayRestartHandoffSync ( env , 2_001 ) ) . toBeNull ( ) ;
161-
162- fs . writeFileSync ( handoffPath ( env ) , "x" . repeat ( 8192 ) , { encoding : "utf8" , mode : 0o600 } ) ;
163- expect ( readGatewayRestartHandoffSync ( env , 2_001 ) ) . toBeNull ( ) ;
164235 } ) ;
165236
166237 it ( "rejects persisted handoffs with a ttl longer than the supported window" , ( ) => {
167238 const env = createHandoffEnv ( ) ;
168239
169- fs . writeFileSync (
170- handoffPath ( env ) ,
171- `${ JSON . stringify ( {
172- kind : GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND ,
173- version : 1 ,
174- intentId : "too-long" ,
175- pid : 111 ,
176- createdAt : 1_000 ,
177- expiresAt : 61_001 ,
178- source : "plugin-change" ,
179- restartKind : "full-process" ,
180- supervisorMode : "external" ,
181- } ) } \n`,
182- { encoding : "utf8" , mode : 0o600 } ,
183- ) ;
240+ insertHandoffRow ( env , { intentId : "too-long" , createdAt : 1_000 , expiresAt : 61_001 } ) ;
184241
185242 expect ( readGatewayRestartHandoffSync ( env , 1_001 ) ) . toBeNull ( ) ;
186243 } ) ;
187244
188- it ( "does not follow an existing handoff-path symlink when writing " , ( ) => {
245+ it ( "overwrites the previous pending handoff row " , ( ) => {
189246 const env = createHandoffEnv ( ) ;
190- const targetPath = path . join ( env . OPENCLAW_STATE_DIR ?? "" , "attacker-target.txt" ) ;
191- fs . writeFileSync ( targetPath , "keep" , "utf8" ) ;
192- try {
193- fs . symlinkSync ( targetPath , handoffPath ( env ) ) ;
194- } catch {
195- return ;
196- }
197247
198248 expectWrittenHandoff ( {
199249 env,
200250 pid : 12_345 ,
201251 restartKind : "full-process" ,
202252 supervisorMode : "external" ,
203253 } ) ;
254+ expectWrittenHandoff ( {
255+ env,
256+ pid : 67_890 ,
257+ reason : "gateway.restart" ,
258+ restartKind : "update-process" ,
259+ supervisorMode : "systemd" ,
260+ } ) ;
204261
205- expect ( fs . readFileSync ( targetPath , "utf8" ) ) . toBe ( "keep" ) ;
206- expect ( fs . lstatSync ( handoffPath ( env ) ) . isSymbolicLink ( ) ) . toBe ( false ) ;
207- expect ( readGatewayRestartHandoffSync ( env ) ?. pid ) . toBe ( 12_345 ) ;
262+ expect ( readHandoffRow ( env ) ) . toMatchObject ( {
263+ handoff_key : "current" ,
264+ pid : 67_890 ,
265+ reason : "gateway.restart" ,
266+ source : "operator-restart" ,
267+ restart_kind : "update-process" ,
268+ supervisor_mode : "systemd" ,
269+ } ) ;
270+ expect ( readGatewayRestartHandoffSync ( env ) ?. pid ) . toBe ( 67_890 ) ;
271+ expect ( fs . existsSync ( legacyHandoffPath ( env ) ) ) . toBe ( false ) ;
208272 } ) ;
209273
210274 it ( "formats a concise diagnostic line for status surfaces" , ( ) => {
0 commit comments