This repository was archived by the owner on May 13, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun.sh
More file actions
executable file
·1155 lines (1054 loc) · 44.2 KB
/
Copy pathrun.sh
File metadata and controls
executable file
·1155 lines (1054 loc) · 44.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/bin/bash
set -e
#######################################
# FoldDB Development Server
#
# Usage:
# ./run.sh [OPTIONS]
#
# Options:
# --local Use local Sled storage (default, kept for compatibility)
# --exemem Exemem cloud sync mode (local Sled + encrypted sync)
# --local-schema Run local schema service (for offline development)
# --dev Use dev schema service (default: prod)
# --reset-db Reset database from test_db template
# --empty-db Start with empty database
# --demo Use isolated demo directories ($FOLDDB_HOME/demo-data, demo-config)
# --region=REGION Legacy flag, ignored
# --home <path> Set FOLDDB_HOME (default: .folddb relative to CWD)
# --port <port> HTTP server port (default: auto-slot in 9101..=9199,
# or value of FOLDDB_PORT env var)
# --schema-port <port> Schema service port (default: <http_port> + 1)
# --list-slots Print ~/.folddb-slots/ status (PID alive, port bound,
# home dir) and exit without starting anything.
#
# Environment Variables:
# FOLDDB_HOME Where all instance-specific state lives (default: .folddb)
# FOLDDB_PORT HTTP server port (alternative to --port)
# VITE_PORT Vite frontend port (pin to a specific port; disables scan)
# VITE_PORT_BASE First port in the Vite auto-slot scan (default: 5173)
# VITE_PORT_COUNT How many ports the scan covers (default: 127 → 5173..=5299)
#
# Examples:
# ./run.sh # Local Sled mode with prod schema service
# ./run.sh --dev # Local Sled mode with dev schema service
# ./run.sh --local # Local storage with global schema service
# ./run.sh --local --local-schema # Fully offline development
# ./run.sh --local --empty-db # Local with fresh database
# ./run.sh --exemem # Exemem cloud sync mode (requires EXEMEM_API_KEY)
# ./run.sh --home /tmp/node2 --port 9003 --local --local-schema
#######################################
# ============================================================================
# Shared Functions
# ============================================================================
# Kill a process by reading its PID from a file.
# Usage: kill_pid_file <path>
kill_pid_file() {
local pidfile="$1"
if [ -f "$pidfile" ]; then
local pid
pid=$(cat "$pidfile" 2>/dev/null)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "Stopping process $pid (from $pidfile)..."
kill "$pid" 2>/dev/null || true
# Wait up to 3 seconds for graceful shutdown
for i in 1 2 3; do
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
# Force kill if still alive
if kill -0 "$pid" 2>/dev/null; then
kill -9 "$pid" 2>/dev/null || true
fi
fi
rm -f "$pidfile"
fi
}
cleanup_processes() {
echo "Checking for existing fold_db processes..."
# PID-based cleanup — only kill processes we started
kill_pid_file "$FOLDDB_HOME/folddb.pid"
kill_pid_file "$FOLDDB_HOME/schema.pid"
kill_pid_file "$FOLDDB_HOME/vite.pid"
echo "Cleaned up existing processes."
}
# Pull the --port value out of a folddb_server argv string. Echoes the port
# number, or nothing if --port wasn't present. Tolerates `--port N` and
# `--port=N`. Word-matches `--port` so it doesn't also match `--schema-port`.
parse_folddb_port() {
local cmd="$1"
echo "$cmd" | awk '
{
for (i = 1; i <= NF; i++) {
if ($i == "--port" && i < NF) { print $(i+1); exit }
if (match($i, /^--port=[0-9]+$/)) { print substr($i, 8); exit }
}
}
'
}
# Find the PID of a folddb_server owned by the current user whose argv carries
# `--port <target_port>`. Echoes the PID, or nothing if no match.
folddb_server_pid_for_port() {
local target_port="$1" pid cmd port
while IFS= read -r pid; do
[ -z "$pid" ] && continue
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
[ -z "$cmd" ] && continue
port="$(parse_folddb_port "$cmd")"
if [ "$port" = "$target_port" ]; then
echo "$pid"
return 0
fi
done < <(pgrep -u "$(id -un)" -f folddb_server 2>/dev/null || true)
return 0
}
# Print one row per slot file in ~/.folddb-slots/ summarising whether the
# wrapper PID is alive, whether anything is listening on the port, and the
# slot's home directory. Read-only diagnostic — does NOT reap or touch any
# state. Exits 0 when there are no slots to print. Used by `--list-slots`.
list_slots() {
local slot_dir="$HOME/.folddb-slots"
if [ ! -d "$slot_dir" ]; then
echo "(no slot directory at $slot_dir)"
return 0
fi
local any=false
local printed_header=false
for slot_file in "$slot_dir"/*.json; do
[ -e "$slot_file" ] || continue
if [ "$printed_header" = false ]; then
printf '%-6s %-7s %-7s %-7s %-7s %s\n' \
"PORT" "PID" "ALIVE" "WRAPPER" "LISTEN" "HOME"
printed_header=true
fi
any=true
local owner_pid slot_home slot_port
owner_pid="$(grep -oE '"pid":[[:space:]]*[0-9]+' "$slot_file" 2>/dev/null | grep -oE '[0-9]+$' || true)"
slot_home="$(grep -oE '"home":[[:space:]]*"[^"]*"' "$slot_file" 2>/dev/null | sed -E 's/.*"home":[[:space:]]*"([^"]*)".*/\1/' || true)"
slot_port="$(grep -oE '"port":[[:space:]]*[0-9]+' "$slot_file" 2>/dev/null | grep -oE '[0-9]+$' || true)"
local alive=no wrapper=no listen=no
if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then
alive=yes
if ps -p "$owner_pid" -o command= 2>/dev/null | grep -qE 'run\.sh|folddb_server'; then
wrapper=yes
fi
fi
if [ -n "$slot_port" ] && lsof -iTCP:"$slot_port" -sTCP:LISTEN -t >/dev/null 2>&1; then
listen=yes
fi
printf '%-6s %-7s %-7s %-7s %-7s %s\n' \
"${slot_port:-?}" "${owner_pid:-?}" "$alive" "$wrapper" "$listen" "${slot_home:-?}"
done
[ "$any" = false ] && echo "(no slot files in $slot_dir)"
return 0
}
# Slot startup grace window in seconds. Between slot-file creation and the
# folddb_server actually binding the port, there's a build+boot window
# (cargo build can be 30-60s, plus SERVER_TIMEOUT=60). During that window
# the listener is legitimately unbound, so the listener-liveness reaper
# below MUST NOT touch slots younger than this. 180s leaves margin for a
# cold cargo build on a slow machine.
SLOT_STARTUP_GRACE_SECONDS=180
# Echo the age (in seconds) of $1 based on mtime, or 0 on failure. Handles
# both BSD `stat -f` (macOS) and GNU `stat -c` (Linux) flavors.
slot_file_age_seconds() {
local f="$1" mtime
mtime="$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0)"
[ "$mtime" -gt 0 ] || { echo 0; return 0; }
echo $(( $(date +%s) - mtime ))
}
# Echo "true" if anything is listening on TCP $1, otherwise "false". Same
# lsof idiom used elsewhere in the script — handles IPv4 + IPv6 listeners.
port_is_bound() {
local port="$1"
[ -n "$port" ] || { echo false; return 0; }
if lsof -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then
echo true
else
echo false
fi
}
# Reap slot files in ~/.folddb-slots/ that no longer correspond to an active
# session. A slot is stale when ANY of:
# - The owning run.sh PID is dead (or PID was reused by something else),
# - The slot's home directory has been deleted (worktree GC'd while the
# server kept running — child was nohup'd),
# - The wrapper is alive but the listener it spawned has died and the slot
# is past its startup grace (zombie-wrapper case).
# For each stale slot kill any lingering server children we can identify
# (and the zombie wrapper, if any), then remove the slot file. Runs at
# startup so successive invocations clean up after predecessors that were
# SIGKILL'd, whose parent agent crashed before the EXIT trap could fire, or
# whose folddb_server panicked underneath a still-running wrapper.
sweep_dead_slots() {
local slot_dir="$HOME/.folddb-slots"
[ -d "$slot_dir" ] || return 0
local cleaned=0
for slot_file in "$slot_dir"/*.json; do
[ -e "$slot_file" ] || continue
local owner_pid slot_home slot_port
# Tolerant parsing — missing fields just skip the slot.
owner_pid="$(grep -oE '"pid":[[:space:]]*[0-9]+' "$slot_file" 2>/dev/null | grep -oE '[0-9]+$' || true)"
slot_home="$(grep -oE '"home":[[:space:]]*"[^"]*"' "$slot_file" 2>/dev/null | sed -E 's/.*"home":[[:space:]]*"([^"]*)".*/\1/' || true)"
slot_port="$(grep -oE '"port":[[:space:]]*[0-9]+' "$slot_file" 2>/dev/null | grep -oE '[0-9]+$' || true)"
# Owner is "alive" only if (a) the PID exists AND (b) its argv still
# references run.sh or folddb_server. The argv check guards against
# PID reuse: when a run.sh dies ungracefully and the OS recycles its
# PID for an unrelated process, the slot would otherwise stay orphaned
# forever because every sweep sees the alive PID and skips it.
local owner_alive=false
if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null \
&& ps -p "$owner_pid" -o command= 2>/dev/null | grep -qE 'run\.sh|folddb_server'; then
owner_alive=true
fi
local home_present=false
if [ -n "$slot_home" ] && [ -d "$slot_home" ]; then
home_present=true
fi
# Listener-liveness check. The slot's `pid` is the run.sh wrapper,
# not the folddb_server; the wrapper can stay alive long after its
# forked server has crashed (e.g. cargo panic at boot, OOM kill),
# leaving the slot file claiming a port that's actually free. Reap
# those — but only after the startup grace window, otherwise we'd
# race a sibling run.sh that's still cargo-building.
local listener_bound slot_age
listener_bound="$(port_is_bound "$slot_port")"
slot_age="$(slot_file_age_seconds "$slot_file")"
# Active session: owner run.sh alive AND home dir still there AND
# either the listener is bound or the slot is still inside its
# startup grace window.
if [ "$owner_alive" = true ] && [ "$home_present" = true ]; then
if [ "$listener_bound" = true ] || [ "$slot_age" -lt "$SLOT_STARTUP_GRACE_SECONDS" ]; then
continue
fi
# Zombie wrapper: alive but its folddb_server died and we're
# past startup grace. Kill the wrapper so it stops holding the
# slot, then fall through to the reap path below.
echo "Reaping zombie wrapper (port=$slot_port pid=$owner_pid alive but listener dead for ${slot_age}s)"
kill "$owner_pid" 2>/dev/null || true
sleep 1
kill -9 "$owner_pid" 2>/dev/null || true
fi
# Inconclusive: owner alive but home parse failed (no slot_home in
# JSON). Treat as active to avoid killing a session we can't classify.
if [ "$owner_alive" = true ] && [ -z "$slot_home" ]; then
continue
fi
if [ "$home_present" = true ]; then
# Home alive, owner dead — kill children via slot's PID files.
kill_pid_file "$slot_home/folddb.pid"
kill_pid_file "$slot_home/schema.pid"
kill_pid_file "$slot_home/vite.pid"
elif [ -n "$slot_port" ]; then
# Worktree GC'd while server lived. The slot's `pid` field is
# the run.sh's, not the server's, so look up folddb_server by
# --port argv match and kill it directly.
local server_pid
server_pid="$(folddb_server_pid_for_port "$slot_port")"
if [ -n "$server_pid" ]; then
echo "Cleaning up stale folddb_server (port=$slot_port pid=$server_pid, worktree gone)"
kill -TERM "$server_pid" 2>/dev/null || true
sleep 2
kill -KILL "$server_pid" 2>/dev/null || true
fi
fi
# Belt-and-braces: kill anything still listening on the slot's port —
# catches the case where slot_home was deleted before the server was.
if [ -n "$slot_port" ]; then
local stuck
stuck="$(lsof -nP -iTCP:"$slot_port" -sTCP:LISTEN -t 2>/dev/null || true)"
if [ -n "$stuck" ]; then
kill $stuck 2>/dev/null || true
sleep 1
kill -9 $stuck 2>/dev/null || true
fi
fi
rm -f "$slot_file"
cleaned=$((cleaned + 1))
done
[ $cleaned -gt 0 ] && echo "Reaped $cleaned stale slot file(s) from crashed prior session(s)."
return 0
}
# Scan ps for current-uid folddb_server processes whose `--port` falls in the
# auto-slot range (9101..=9199) but is not claimed by any current slot file in
# ~/.folddb-slots/. Catches orphans whose slot file was already cleaned (e.g.
# tmp purge) but whose process was never killed because the EXIT trap didn't
# fire. The prod Tauri bundle owns 9001..=9010 and is intentionally excluded.
# Batches the kill: TERM all, sleep 2, KILL stragglers — so cost is constant
# in the number of orphans rather than 2s per orphan.
sweep_orphan_servers() {
local slot_dir="$HOME/.folddb-slots"
local stale_pids=()
while IFS= read -r pid; do
[ -z "$pid" ] && continue
local cmd port
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
[ -z "$cmd" ] && continue
port="$(parse_folddb_port "$cmd")"
[ -z "$port" ] && continue
# Stay in the dev auto-slot range — never touch the prod Tauri bundle.
[ "$port" -ge 9101 ] && [ "$port" -le 9199 ] || continue
# Slot file claims this port → currently-active worktree's server.
[ -f "$slot_dir/$port.json" ] && continue
echo "Cleaning up stale folddb_server (port=$port pid=$pid, no slot file)"
kill -TERM "$pid" 2>/dev/null || true
stale_pids+=("$pid")
done < <(pgrep -u "$(id -un)" -f folddb_server 2>/dev/null || true)
if [ ${#stale_pids[@]} -gt 0 ]; then
sleep 2
local pid
for pid in "${stale_pids[@]}"; do
kill -KILL "$pid" 2>/dev/null || true
done
fi
return 0
}
# Status-only command: `--list-slots` prints the current ~/.folddb-slots
# state and exits without mutating anything. Detected here (before sweep_dead_slots,
# before installing the EXIT trap, before reading any FOLDDB_HOME state) so the
# diagnostic reflects ground truth at invocation time. Useful when figuring
# out why a port appears stuck.
for _arg in "$@"; do
if [ "$_arg" = "--list-slots" ]; then
list_slots
exit 0
fi
done
# Cleanup handler for script exit
on_exit() {
echo "Shutting down..."
kill_pid_file "$FOLDDB_HOME/folddb.pid"
kill_pid_file "$FOLDDB_HOME/schema.pid"
kill_pid_file "$FOLDDB_HOME/vite.pid"
# Remove the auto-slot discovery file — but only if it's STILL ours.
# Without the PID guard, a later run.sh that clobbered our slot file
# would lose its slot when we exit, leaving a live server unfindable.
if [ "${AUTO_SLOT:-}" = true ] && [ -n "$HTTP_PORT" ]; then
local slot_file="$HOME/.folddb-slots/$HTTP_PORT.json"
if [ -f "$slot_file" ] && grep -qE "\"pid\"[[:space:]]*:[[:space:]]*$$\\b" "$slot_file" 2>/dev/null; then
rm -f "$slot_file" 2>/dev/null || true
fi
fi
}
trap on_exit EXIT
# Reap slot/server state from prior sessions before we pick our own slot.
sweep_dead_slots
sweep_orphan_servers
reset_db() {
echo "Resetting database from test_db template..."
rm -rf "$FOLDDB_HOME/data"
cp -R test_db "$FOLDDB_HOME/data"
echo "Database reset complete."
}
empty_db() {
echo "Initializing empty database directory..."
rm -rf "$FOLDDB_HOME/data"
mkdir -p "$FOLDDB_HOME/data"
echo "Empty database directory ready."
}
load_api_keys() {
# Load shell profile to get API keys
# Temporarily disable set -e because shell profiles often have commands
# that return non-zero (completions, conda init, etc.)
set +e
source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true
set -e
if [ -n "$ANTHROPIC_API_KEY" ]; then
export ANTHROPIC_API_KEY
echo "Anthropic API key configured"
else
echo "NOTE: ANTHROPIC_API_KEY not set. Configure AI provider in the UI or set it in your shell profile."
fi
}
check_schema_service() {
local url="$1"
echo "Checking schema service connectivity..."
# Submodule binary serves routes under /v1/ (old /api/ prefix dropped in Phase 0).
if curl -s --connect-timeout 10 "$url/v1/health" > /dev/null 2>&1; then
echo "Schema service is reachable."
return 0
else
return 1
fi
}
# Resolve the absolute path to a sibling `schema_service` checkout.
# Priority:
# 1. $FOLDDB_SCHEMA_SERVICE_DIR (escape hatch — tested first).
# 2. Sibling of the main worktree. `git rev-parse --git-common-dir` returns
# the original .git directory even from a linked worktree, so its parent
# is the main repo root regardless of where this script runs from. This
# is what makes --local-schema work from ~/.cline/worktrees/<id>/...
# 3. $HOME/code/edgevector/schema_service (canonical layout fallback).
# On miss, prints an actionable message naming each path tried and returns 1.
resolve_schema_service_dir() {
if [ -n "$FOLDDB_SCHEMA_SERVICE_DIR" ]; then
if [ -d "$FOLDDB_SCHEMA_SERVICE_DIR" ]; then
( cd "$FOLDDB_SCHEMA_SERVICE_DIR" && pwd )
return 0
fi
echo "ERROR: FOLDDB_SCHEMA_SERVICE_DIR='$FOLDDB_SCHEMA_SERVICE_DIR' but that directory does not exist." >&2
echo " Either fix the path or unset FOLDDB_SCHEMA_SERVICE_DIR to fall back to auto-detection." >&2
return 1
fi
local common_dir main_repo candidate
common_dir="$(git rev-parse --git-common-dir 2>/dev/null || true)"
if [ -n "$common_dir" ] && [ -d "$common_dir" ]; then
main_repo="$(cd "$common_dir/.." && pwd)"
candidate="$main_repo/../schema_service"
if [ -d "$candidate" ]; then
( cd "$candidate" && pwd )
return 0
fi
fi
candidate="$HOME/code/edgevector/schema_service"
if [ -d "$candidate" ]; then
( cd "$candidate" && pwd )
return 0
fi
echo "ERROR: --local-schema requires a sibling 'schema_service' git checkout, but none was found." >&2
echo " Tried (in order):" >&2
echo " 1. \$FOLDDB_SCHEMA_SERVICE_DIR (unset)" >&2
if [ -n "$common_dir" ]; then
echo " 2. <main-worktree>/../schema_service (resolved to '$main_repo/../schema_service')" >&2
else
echo " 2. <main-worktree>/../schema_service (skipped — not a git checkout)" >&2
fi
echo " 3. \$HOME/code/edgevector/schema_service" >&2
echo "" >&2
echo " Fix one of:" >&2
echo " - git clone https://github.com/EdgeVector/schema_service.git \$HOME/code/edgevector/schema_service" >&2
echo " - export FOLDDB_SCHEMA_SERVICE_DIR=/path/to/your/schema_service checkout" >&2
echo " - drop --local-schema to use the configured remote schema service" >&2
return 1
}
# Defensive probe: schema_service/crates/wasm_compiler/build.rs uses env!("CARGO_MANIFEST_DIR")
# (compile-time), which bakes an absolute path into the build-script binary. When a previous
# build happened under a kanban worktree that's since been deleted, cargo's fingerprint sees
# no source change and reuses the cached binary — which then panics reading the allowlist via
# the vanished path. Detect that case and surgically clear just the wasm_compiler artifact so
# cargo rebuilds the script with the current path. Upstream fix is a one-liner in
# schema_service (read CARGO_MANIFEST_DIR at runtime instead).
detect_stale_wasm_compiler_artifact() {
local schema_dir="$1"
local script_glob="$schema_dir/target/debug/build/wasm_compiler-*/build-script-build"
command -v strings >/dev/null 2>&1 || return 0
local first
first=$(compgen -G "$script_glob" 2>/dev/null | head -1)
[ -z "$first" ] && return 0
local baked
baked=$(strings "$first" 2>/dev/null | grep -m1 -oE '/Users/[^[:space:]]*/schema_service/crates/wasm_compiler' || true)
[ -z "$baked" ] && return 0
[ -d "$baked" ] && return 0
echo "[run.sh] wasm_compiler: cleared stale build artifact baked with vanished path: $baked"
( cd "$schema_dir" && cargo clean -p wasm_compiler 2>&1 | tail -3 ) || true
}
start_local_schema_service() {
# Schema service auto-detects its AI provider:
# - ANTHROPIC_API_KEY set → Anthropic (fast, accurate classification via Haiku)
# - No API key → Ollama (local, needs model config)
# We do NOT force AI_PROVIDER=ollama — let it prefer Anthropic when available.
local has_anthropic_key=false
[ -n "$ANTHROPIC_API_KEY" ] && has_anthropic_key=true
if [ "$has_anthropic_key" = true ]; then
echo "Starting LOCAL schema service on port $SCHEMA_PORT (Anthropic for classification)..."
else
echo "Starting LOCAL schema service on port $SCHEMA_PORT (Ollama for classification)..."
fi
# Read Ollama model/URL from saved config (used when Anthropic key is absent)
local config_file="${FOLDDB_HOME}/config/ingestion_config.json"
local ollama_model=""
local ollama_url=""
if [ -f "$config_file" ]; then
ollama_model=$(python3 -c "import json; c=json.load(open('$config_file')); print(c.get('ollama',{}).get('model',''))" 2>/dev/null)
ollama_url=$(python3 -c "import json; c=json.load(open('$config_file')); print(c.get('ollama',{}).get('base_url',''))" 2>/dev/null)
fi
# If no saved config, detect a safe default based on system RAM.
# Without this, the schema service falls back to OLLAMA_DEFAULT (llama3.3 / 70B)
# which most users don't have installed.
if [ -z "$ollama_model" ]; then
local ram_gb
ram_gb=$(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%d", $1/1073741824}')
if [ -n "$ram_gb" ] && [ "$ram_gb" -ge 64 ] 2>/dev/null; then
ollama_model="llama3.3"
elif [ -n "$ram_gb" ] && [ "$ram_gb" -ge 32 ] 2>/dev/null; then
ollama_model="llama3.1:8b"
else
ollama_model="llama3.2:3b"
fi
fi
[ -n "$ollama_model" ] && echo " Ollama model (fallback): $ollama_model"
[ -n "$ollama_url" ] && echo " Ollama URL: $ollama_url"
# Pass Ollama config as fallback — schema service uses Anthropic when ANTHROPIC_API_KEY is set
local schema_env=""
[ -n "$ollama_model" ] && schema_env="$schema_env OLLAMA_MODEL=$ollama_model"
[ -n "$ollama_url" ] && schema_env="$schema_env OLLAMA_BASE_URL=$ollama_url"
# Phase 0 T3: the dev binary moved to the sibling submodule at
# ../schema_service. Resolve absolute paths for everything we pass to the
# spawned process, then enter the submodule so cargo picks up its
# .cargo/config.toml (which patches the fold_db git dep to ../fold_db for
# local dev). Pre-build synchronously so the 30s liveness loop below
# doesn't race a cold cargo build.
local schema_service_dir home_abs schema_db_path schema_log
schema_service_dir="$(resolve_schema_service_dir)" || exit 1
home_abs="$(cd "$FOLDDB_HOME" && pwd)"
schema_db_path="$home_abs/schema_registry"
schema_log="$home_abs/schema_service.log"
detect_stale_wasm_compiler_artifact "$schema_service_dir"
echo "Building schema_service binary from $schema_service_dir..."
( cd "$schema_service_dir" && cargo build -p schema_service_server_http --bin schema_service )
# Exec the freshly built binary directly. Using `cargo run` here would
# re-emit "Finished"/"Running" lines plus any compiler warnings into
# $schema_log, which the in-app LogSidebar then renders as if it were
# runtime output.
pushd "$schema_service_dir" > /dev/null
nohup env $schema_env ./target/debug/schema_service --port "$SCHEMA_PORT" --db-path "$schema_db_path" > "$schema_log" 2>&1 &
SCHEMA_SERVICE_PID=$!
popd > /dev/null
echo "$SCHEMA_SERVICE_PID" > "$FOLDDB_HOME/schema.pid"
echo "Waiting for local schema service to be ready..."
for i in {1..30}; do
if kill -0 $SCHEMA_SERVICE_PID 2>/dev/null; then
# Submodule binary serves routes under /v1/ (old /api/ prefix dropped in Phase 0).
if curl -s "http://127.0.0.1:${SCHEMA_PORT}/v1/health" > /dev/null 2>&1; then
echo "Local schema service started successfully with PID: $SCHEMA_SERVICE_PID"
echo "Schema service logs: $schema_log"
return 0
fi
sleep 1
else
echo "Schema service process died. Check $schema_log for details."
exit 1
fi
done
echo "Local schema service failed to become healthy within 30 seconds."
kill $SCHEMA_SERVICE_PID 2>/dev/null || true
rm -f "$FOLDDB_HOME/schema.pid"
exit 1
}
build_project() {
local features="$1"
echo "Building the Rust project..."
if [ -n "$features" ]; then
cargo build --features "$features"
else
cargo build
fi
if [ $? -ne 0 ]; then
echo "Rust build failed. Exiting."
exit 1
fi
}
generate_openapi() {
local features="$1"
echo "Generating OpenAPI spec..."
mkdir -p target
if [ -n "$features" ]; then
cargo run --features "$features" --quiet --bin openapi_dump > target/openapi.json
else
cargo run --quiet --bin openapi_dump > target/openapi.json
fi
if [ $? -ne 0 ]; then
echo "Failed to generate OpenAPI spec. Exiting."
exit 1
fi
}
install_frontend_deps() {
cd src/server/static-react
local needs_install=false
local reason=""
if [ ! -d "node_modules" ] || [ ! -x "node_modules/.bin/vite" ]; then
needs_install=true
reason="missing or corrupted node_modules"
rm -rf node_modules
elif [ ! -f "node_modules/.package-lock.json" ]; then
# npm writes .package-lock.json on every successful install; absence means stale.
needs_install=true
reason="node_modules/.package-lock.json missing (stale install)"
elif [ "package-lock.json" -nt "node_modules/.package-lock.json" ]; then
needs_install=true
reason="package-lock.json newer than installed deps"
elif [ "package.json" -nt "node_modules/.package-lock.json" ]; then
needs_install=true
reason="package.json newer than installed deps"
fi
if [ "$needs_install" = true ]; then
echo "Installing frontend dependencies ($reason)..."
npm install
if [ $? -ne 0 ]; then
echo "Failed to install frontend dependencies. Exiting."
exit 1
fi
fi
cd ../../..
}
start_http_server() {
local features="$1"
local schema_url="$2"
local timeout="$3"
local demo_flag="$4"
local extra_args=""
if [ "$demo_flag" = true ]; then
extra_args="--demo"
fi
# Preflight: fail fast with a clear error if something else is already
# bound to $HTTP_PORT. Otherwise folddb_server crashes with EADDRINUSE
# but Vite (started later) happily serves a dead UI that 401s forever.
local holder
holder=$(lsof -iTCP:"$HTTP_PORT" -sTCP:LISTEN -t 2>/dev/null | head -1)
if [ -n "$holder" ]; then
local holder_cmd
holder_cmd=$(ps -o command= -p "$holder" 2>/dev/null || echo "<unknown>")
echo "error: port $HTTP_PORT is already bound by PID $holder ($holder_cmd)." >&2
echo " Stop that process or rerun without --port / FOLDDB_PORT to auto-slot a free port." >&2
return 1
fi
# Build first with output going to stderr — visible to the user but kept
# OUT of server.log. Then exec the freshly built binary so server.log
# only carries runtime tracing, which is what the in-app LogSidebar
# consumes. `cargo run` here would re-emit "Compiling/Finished/Running"
# plus any compiler warnings into the redirected stdout/stderr, polluting
# the log.
echo "Building folddb_server..."
if [ -n "$features" ]; then
cargo build --features "$features" --bin folddb_server >&2
else
cargo build --bin folddb_server >&2
fi
echo "Starting the HTTP server on port $HTTP_PORT..."
# Default RUST_LOG to debug for local dev so operators see the full
# picture, but honor an operator-supplied value (e.g.
# `RUST_LOG=trace ./run.sh ...` or a stricter setting). The binary
# additionally caps a handful of chatty channels (sled,
# fold_db::fold_db_core::mutation_manager, fold_db::db_operations::atom_store)
# at INFO unless overridden via FOLDDB_LOG_* — see src/log_filter.rs.
FOLDDB_HOME="$FOLDDB_HOME" RUST_LOG="${RUST_LOG:-debug}" nohup ./target/debug/folddb_server --port "$HTTP_PORT" --schema-service-url "$schema_url" $extra_args > "$FOLDDB_HOME/server.log" 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > "$FOLDDB_HOME/folddb.pid"
echo "Waiting for HTTP server to be ready..."
for i in $(seq 1 $timeout); do
if kill -0 $SERVER_PID 2>/dev/null; then
if curl -s "http://127.0.0.1:${HTTP_PORT}/api/system/status" > /dev/null 2>&1; then
echo "HTTP server started successfully with PID: $SERVER_PID"
echo "Server logs: $FOLDDB_HOME/server.log"
return 0
fi
sleep 1
else
echo "HTTP server process died. Tail of $FOLDDB_HOME/server.log:" >&2
tail -n 20 "$FOLDDB_HOME/server.log" >&2 2>/dev/null || true
return 1
fi
done
echo "HTTP server failed to become healthy within $timeout seconds." >&2
echo "Tail of $FOLDDB_HOME/server.log:" >&2
tail -n 20 "$FOLDDB_HOME/server.log" >&2 2>/dev/null || true
kill $SERVER_PID 2>/dev/null || true
rm -f "$FOLDDB_HOME/folddb.pid"
return 1
}
start_vite_dev() {
echo ""
echo "Starting Vite dev server with hot reload..."
echo "Access app at: http://localhost:$VITE_PORT"
echo ""
cd src/server/static-react
export VITE_ENABLE_SAMPLES=true
export VITE_API_PORT="$HTTP_PORT"
npm run dev -- --port "$VITE_PORT" --strictPort
}
# ============================================================================
# Parse Arguments
# ============================================================================
LOCAL_MODE=false
EXEMEM_MODE=false
LOCAL_SCHEMA=false
DEV_MODE=false
RESET_DB=false
EMPTY_DB=false
DEMO_MODE=false
# Auto-slot: when neither --port nor FOLDDB_PORT is set, pick the first free
# port in 9101..=9199 so N parallel agents can each run their own fold_db
# instance without any coordination. If FOLDDB_HOME is also unset, derive
# a per-slot FOLDDB_HOME from the chosen port; otherwise preserve the
# caller's FOLDDB_HOME. The prod Tauri bundle owns 9001; dev lives in the
# 9101 range.
HTTP_PORT=""
SCHEMA_PORT=""
AUTO_SLOT=false
if [ -n "$FOLDDB_PORT" ]; then
HTTP_PORT="$FOLDDB_PORT"
fi
for arg in "$@"; do
case "$arg" in
--local)
LOCAL_MODE=true
;;
--exemem)
EXEMEM_MODE=true
;;
--local-schema)
LOCAL_SCHEMA=true
;;
--dev)
DEV_MODE=true
;;
--reset-db)
RESET_DB=true
;;
--empty-db)
EMPTY_DB=true
;;
--demo)
DEMO_MODE=true
;;
--region=*)
# Legacy flag, ignored
;;
--home)
# Handled below via positional peek
;;
--home=*)
FOLDDB_HOME="${arg#*=}"
;;
--port)
# Handled below via positional peek
;;
--port=*)
HTTP_PORT="${arg#*=}"
;;
--schema-port)
# Handled below via positional peek
;;
--schema-port=*)
SCHEMA_PORT="${arg#*=}"
;;
--help|-h)
head -42 "$0" | tail -37
exit 0
;;
*)
;;
esac
done
# Handle --home <value>, --port <value>, --schema-port <value> (space-separated)
args=("$@")
for i in "${!args[@]}"; do
case "${args[$i]}" in
--home)
FOLDDB_HOME="${args[$((i+1))]}"
;;
--port)
HTTP_PORT="${args[$((i+1))]}"
;;
--schema-port)
SCHEMA_PORT="${args[$((i+1))]}"
;;
esac
done
# Auto-slot: if no HTTP port was pinned (no --port, no FOLDDB_PORT), scan
# 9101..=9199 for a free port so parallel agents don't collide. An explicit
# FOLDDB_HOME does NOT disable the port scan — the caller may want an
# isolated data dir but still let us find a free port for them. If
# FOLDDB_HOME is also unset we derive a per-slot one from the chosen port;
# otherwise we preserve what the caller set.
#
# Use lsof (not a bash /dev/tcp probe) because folddb_server may be bound to
# an IPv6 listener; a /dev/tcp/127.0.0.1 probe would miss it and hand us a
# port the backend can't actually bind, crashing it with EADDRINUSE while
# Vite (started later) would happily serve a UI talking to nothing.
if [ -z "$HTTP_PORT" ]; then
# Atomic claim: we couple "is the port free" with "no other run.sh has
# already claimed this slot file" via O_EXCL semantics (`set -C`). Two
# parallel run.sh's that both lsof-pass the same candidate would
# otherwise both write the slot file (last-write-wins) and the loser's
# `on_exit` trap would delete the winner's slot.
mkdir -p "$HOME/.folddb-slots" 2>/dev/null || true
for candidate in $(seq 9101 9199); do
if lsof -iTCP:"$candidate" -sTCP:LISTEN -t >/dev/null 2>&1; then
continue
fi
slot_file="$HOME/.folddb-slots/$candidate.json"
if (set -C; : > "$slot_file") 2>/dev/null; then
HTTP_PORT="$candidate"
AUTO_SLOT=true
break
fi
done
if [ -z "$HTTP_PORT" ]; then
echo "error: no free TCP port found in 9101..=9199 — every port that run.sh would try is occupied" >&2
exit 1
fi
if [ -z "$FOLDDB_HOME" ]; then
FOLDDB_HOME="/tmp/folddb-slot-$HTTP_PORT"
echo "[run.sh] auto-slot: port=$HTTP_PORT, home=$FOLDDB_HOME"
else
echo "[run.sh] auto-slot: port=$HTTP_PORT (home=$FOLDDB_HOME preserved)"
fi
fi
# Fill in remaining defaults for whichever of port/home wasn't pinned.
if [ -z "$HTTP_PORT" ]; then
HTTP_PORT=9101
fi
if [ -z "$SCHEMA_PORT" ]; then
SCHEMA_PORT=$((HTTP_PORT + 1))
fi
if [ -z "$FOLDDB_HOME" ]; then
FOLDDB_HOME=".folddb"
fi
export FOLDDB_HOME
# Vite port: scan VITE_PORT_BASE..VITE_PORT_BASE+VITE_PORT_COUNT-1 for the
# first free slot so that parallel `run.sh` invocations don't collide on
# the frontend. Defaults cover 5173..=5299 (127 ports); override
# VITE_PORT_BASE / VITE_PORT_COUNT for stacks that already hold a chunk
# of 5173+. An explicit $VITE_PORT pins a single port and disables the
# scan. Independent from the backend HTTP_PORT auto-slot above — Vite
# port collisions happen even when the backend was explicitly pinned.
#
# Use lsof (not a bash /dev/tcp probe) because Vite binds IPv6 by default;
# a /dev/tcp/127.0.0.1 probe would miss an IPv6-only listener and hand us
# a port Vite can't actually bind.
if [ -z "${VITE_PORT:-}" ]; then
vite_port_base="${VITE_PORT_BASE:-5173}"
vite_port_count="${VITE_PORT_COUNT:-127}"
vite_port_end=$((vite_port_base + vite_port_count - 1))
for candidate in $(seq "$vite_port_base" "$vite_port_end"); do
if ! lsof -iTCP:"$candidate" -sTCP:LISTEN -t >/dev/null 2>&1; then
VITE_PORT="$candidate"
break
fi
done
if [ -z "${VITE_PORT:-}" ]; then
echo "error: no free TCP port found in ${vite_port_base}..=${vite_port_end} for Vite dev server" >&2
echo " Free a port, or widen the range via VITE_PORT_BASE / VITE_PORT_COUNT," >&2
echo " or pin a specific one with VITE_PORT=<port>." >&2
exit 1
fi
fi
export VITE_PORT
# Fill the slot file we atomically claimed above. The empty file already
# exists; this just writes the JSON content. We own the file (set -C above
# guarantees no other run.sh raced us), so the truncate-and-write is safe.
# Best-effort; non-fatal if it fails.
if [ "$AUTO_SLOT" = true ]; then
cat > "$HOME/.folddb-slots/$HTTP_PORT.json" 2>/dev/null <<EOF || true
{"port": $HTTP_PORT, "schema_port": $SCHEMA_PORT, "vite_port": $VITE_PORT, "home": "$FOLDDB_HOME", "pid": $$}
EOF
fi
# Export EXEMEM_ENV so the Rust process picks up the correct environment.
# Default is prod. --dev flag overrides to dev.
if [ "$DEV_MODE" = true ]; then
export EXEMEM_ENV="${EXEMEM_ENV:-dev}"
else
export EXEMEM_ENV="${EXEMEM_ENV:-prod}"
fi
# Resolve the schema service URL once so the persisted node_config.json and the
# in-memory runtime config agree. Without this, debugging is misleading: a
# --local-schema node writes the prod URL to disk even though it talks to
# 127.0.0.1 at runtime.
#
# URLs come from environments.json (single source of truth) via the helper.
SCRIPT_DIR_FOR_REGISTRY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ "$LOCAL_SCHEMA" = true ]; then
CONFIG_SCHEMA_URL="http://127.0.0.1:${SCHEMA_PORT}"
elif [ "$DEV_MODE" = true ]; then
CONFIG_SCHEMA_URL="$("$SCRIPT_DIR_FOR_REGISTRY/scripts/get-env-url.sh" dev schema_service)"
else
CONFIG_SCHEMA_URL="$("$SCRIPT_DIR_FOR_REGISTRY/scripts/get-env-url.sh" prod schema_service)"
fi
# ============================================================================
# Main Script
# ============================================================================
# Ensure FOLDDB_HOME directory exists
mkdir -p "$FOLDDB_HOME"
# Cleanup existing processes (PID-based, only kills our processes)
cleanup_processes
# Handle database reset options
if [ "$RESET_DB" = true ]; then
reset_db
fi
if [ "$EMPTY_DB" = true ]; then
empty_db
fi
# Ensure config directory exists
mkdir -p "$FOLDDB_HOME/config"
CONFIG_FILE="$FOLDDB_HOME/config/node_config.json"
# Set NODE_CONFIG so Rust code finds the config file
export NODE_CONFIG="$CONFIG_FILE"
# Backup existing config
if [ -f "$CONFIG_FILE" ]; then
cp "$CONFIG_FILE" "${CONFIG_FILE}.backup"
fi
# If no explicit mode flag was given and saved config is Exemem, respect it
if [ "$LOCAL_MODE" = false ] && [ "$EXEMEM_MODE" = false ] && [ -f "$CONFIG_FILE" ]; then
SAVED_DB_TYPE=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('database',{}).get('type',''))" 2>/dev/null || echo "")
if [ "$SAVED_DB_TYPE" = "exemem" ]; then
echo "Detected saved Exemem config — preserving it (use --local to override)"
EXEMEM_MODE=true
# Read credentials from saved config so the EXEMEM_MODE branch doesn't fail on empty key
EXEMEM_API_KEY=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE')).get('database',{}).get('api_key',''))" 2>/dev/null || echo "")
export EXEMEM_API_KEY
# Align the persisted schema URL with the effective runtime URL for this
# invocation's flags (--local-schema / --dev / prod default).
python3 -c "
import json
with open('$CONFIG_FILE') as f: cfg = json.load(f)
cfg['schema_service_url'] = '${CONFIG_SCHEMA_URL}'
with open('$CONFIG_FILE', 'w') as f: json.dump(cfg, f, indent=2)
" 2>/dev/null
fi
fi
# Set up configuration based on mode
if [ "$LOCAL_MODE" = true ]; then
echo "Setting up LOCAL configuration (Sled storage)..."
cat > "$CONFIG_FILE" <<EOF
{
"database": {
"type": "local",
"path": "$FOLDDB_HOME/data"
},
"storage_path": "$FOLDDB_HOME/data",
"default_trust_distance": 1,
"network_listen_address": "/ip4/0.0.0.0/tcp/0",
"security_config": {
"require_tls": false,
"encrypt_at_rest": false
},
"schema_service_url": "$CONFIG_SCHEMA_URL"
}
EOF