sensorium-mcp 2.17.27 → 3.0.0

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.
Files changed (224) hide show
  1. package/Install-Sensorium.ps1 +327 -0
  2. package/README.md +14 -0
  3. package/dist/config.d.ts +16 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +39 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/daily-session.d.ts +2 -1
  8. package/dist/daily-session.d.ts.map +1 -1
  9. package/dist/daily-session.js +23 -26
  10. package/dist/daily-session.js.map +1 -1
  11. package/dist/dashboard/routes/settings.d.ts +4 -0
  12. package/dist/dashboard/routes/settings.d.ts.map +1 -1
  13. package/dist/dashboard/routes/settings.js +57 -1
  14. package/dist/dashboard/routes/settings.js.map +1 -1
  15. package/dist/dashboard/routes/threads.d.ts +1 -0
  16. package/dist/dashboard/routes/threads.d.ts.map +1 -1
  17. package/dist/dashboard/routes/threads.js +23 -27
  18. package/dist/dashboard/routes/threads.js.map +1 -1
  19. package/dist/dashboard/routes.d.ts.map +1 -1
  20. package/dist/dashboard/routes.js +7 -2
  21. package/dist/dashboard/routes.js.map +1 -1
  22. package/dist/dashboard/spa.html +11 -11
  23. package/dist/data/interfaces.d.ts +36 -0
  24. package/dist/data/interfaces.d.ts.map +1 -0
  25. package/dist/data/interfaces.js +2 -0
  26. package/dist/data/interfaces.js.map +1 -0
  27. package/dist/data/memory/bootstrap.d.ts +36 -16
  28. package/dist/data/memory/bootstrap.d.ts.map +1 -1
  29. package/dist/data/memory/bootstrap.js +71 -217
  30. package/dist/data/memory/bootstrap.js.map +1 -1
  31. package/dist/data/memory/consolidation.d.ts +35 -34
  32. package/dist/data/memory/consolidation.d.ts.map +1 -1
  33. package/dist/data/memory/consolidation.js +43 -555
  34. package/dist/data/memory/consolidation.js.map +1 -1
  35. package/dist/data/memory/index.d.ts +0 -1
  36. package/dist/data/memory/index.d.ts.map +1 -1
  37. package/dist/data/memory/index.js +0 -1
  38. package/dist/data/memory/index.js.map +1 -1
  39. package/dist/data/memory/migration-runner.d.ts +5 -0
  40. package/dist/data/memory/migration-runner.d.ts.map +1 -0
  41. package/dist/data/memory/migration-runner.js +403 -0
  42. package/dist/data/memory/migration-runner.js.map +1 -0
  43. package/dist/data/memory/reflection.js +1 -1
  44. package/dist/data/memory/schema-ddl.d.ts +4 -0
  45. package/dist/data/memory/schema-ddl.d.ts.map +1 -0
  46. package/dist/data/memory/schema-ddl.js +194 -0
  47. package/dist/data/memory/schema-ddl.js.map +1 -0
  48. package/dist/data/memory/schema-guard.d.ts +3 -0
  49. package/dist/data/memory/schema-guard.d.ts.map +1 -0
  50. package/dist/data/memory/schema-guard.js +184 -0
  51. package/dist/data/memory/schema-guard.js.map +1 -0
  52. package/dist/data/memory/schema.d.ts +2 -5
  53. package/dist/data/memory/schema.d.ts.map +1 -1
  54. package/dist/data/memory/schema.js +6 -834
  55. package/dist/data/memory/schema.js.map +1 -1
  56. package/dist/data/memory/semantic.d.ts +0 -1
  57. package/dist/data/memory/semantic.d.ts.map +1 -1
  58. package/dist/data/memory/semantic.js +2 -8
  59. package/dist/data/memory/semantic.js.map +1 -1
  60. package/dist/data/memory/synthesis.js +2 -2
  61. package/dist/data/memory/synthesis.js.map +1 -1
  62. package/dist/data/memory/thread-registry.d.ts +18 -4
  63. package/dist/data/memory/thread-registry.d.ts.map +1 -1
  64. package/dist/data/memory/thread-registry.js +25 -0
  65. package/dist/data/memory/thread-registry.js.map +1 -1
  66. package/dist/data/sent-message.repository.d.ts +12 -0
  67. package/dist/data/sent-message.repository.d.ts.map +1 -0
  68. package/dist/data/sent-message.repository.js +31 -0
  69. package/dist/data/sent-message.repository.js.map +1 -0
  70. package/dist/http-server.d.ts.map +1 -1
  71. package/dist/http-server.js +23 -2
  72. package/dist/http-server.js.map +1 -1
  73. package/dist/index.js +27 -48
  74. package/dist/index.js.map +1 -1
  75. package/dist/logger.d.ts +7 -2
  76. package/dist/logger.d.ts.map +1 -1
  77. package/dist/logger.js +89 -12
  78. package/dist/logger.js.map +1 -1
  79. package/dist/scheduler.d.ts +8 -0
  80. package/dist/scheduler.d.ts.map +1 -1
  81. package/dist/scheduler.js +15 -0
  82. package/dist/scheduler.js.map +1 -1
  83. package/dist/server/factory.d.ts +2 -1
  84. package/dist/server/factory.d.ts.map +1 -1
  85. package/dist/server/factory.js +11 -4
  86. package/dist/server/factory.js.map +1 -1
  87. package/dist/services/agent-spawn.service.d.ts +39 -0
  88. package/dist/services/agent-spawn.service.d.ts.map +1 -0
  89. package/dist/services/agent-spawn.service.js +348 -0
  90. package/dist/services/agent-spawn.service.js.map +1 -0
  91. package/dist/services/background-runner.d.ts +26 -0
  92. package/dist/services/background-runner.d.ts.map +1 -0
  93. package/dist/services/background-runner.js +71 -0
  94. package/dist/services/background-runner.js.map +1 -0
  95. package/dist/services/consolidation.service.d.ts +16 -0
  96. package/dist/services/consolidation.service.d.ts.map +1 -0
  97. package/dist/services/consolidation.service.js +508 -0
  98. package/dist/services/consolidation.service.js.map +1 -0
  99. package/dist/services/dispatcher/broker.d.ts +2 -0
  100. package/dist/services/dispatcher/broker.d.ts.map +1 -1
  101. package/dist/services/dispatcher/broker.js +5 -10
  102. package/dist/services/dispatcher/broker.js.map +1 -1
  103. package/dist/services/dispatcher/index.d.ts +1 -1
  104. package/dist/services/dispatcher/index.d.ts.map +1 -1
  105. package/dist/services/dispatcher/index.js +1 -1
  106. package/dist/services/dispatcher/index.js.map +1 -1
  107. package/dist/services/dispatcher/lock.d.ts.map +1 -1
  108. package/dist/services/dispatcher/lock.js +7 -11
  109. package/dist/services/dispatcher/lock.js.map +1 -1
  110. package/dist/services/maintenance-signal.d.ts +18 -0
  111. package/dist/services/maintenance-signal.d.ts.map +1 -0
  112. package/dist/services/maintenance-signal.js +48 -0
  113. package/dist/services/maintenance-signal.js.map +1 -0
  114. package/dist/services/memory-briefing.service.d.ts +4 -0
  115. package/dist/services/memory-briefing.service.d.ts.map +1 -0
  116. package/dist/services/memory-briefing.service.js +143 -0
  117. package/dist/services/memory-briefing.service.js.map +1 -0
  118. package/dist/services/process.service.d.ts +31 -0
  119. package/dist/services/process.service.d.ts.map +1 -0
  120. package/dist/services/process.service.js +100 -0
  121. package/dist/services/process.service.js.map +1 -0
  122. package/dist/services/thread-health.service.d.ts +18 -0
  123. package/dist/services/thread-health.service.d.ts.map +1 -0
  124. package/dist/services/thread-health.service.js +118 -0
  125. package/dist/services/thread-health.service.js.map +1 -0
  126. package/dist/services/thread-lifecycle.service.d.ts +52 -0
  127. package/dist/services/thread-lifecycle.service.d.ts.map +1 -0
  128. package/dist/services/thread-lifecycle.service.js +174 -0
  129. package/dist/services/thread-lifecycle.service.js.map +1 -0
  130. package/dist/services/topic.service.d.ts +25 -0
  131. package/dist/services/topic.service.d.ts.map +1 -0
  132. package/dist/services/topic.service.js +65 -0
  133. package/dist/services/topic.service.js.map +1 -0
  134. package/dist/services/worker-cleanup.service.d.ts +8 -0
  135. package/dist/services/worker-cleanup.service.d.ts.map +1 -0
  136. package/dist/services/worker-cleanup.service.js +82 -0
  137. package/dist/services/worker-cleanup.service.js.map +1 -0
  138. package/dist/sessions.d.ts +14 -0
  139. package/dist/sessions.d.ts.map +1 -1
  140. package/dist/sessions.js +55 -0
  141. package/dist/sessions.js.map +1 -1
  142. package/dist/telegram.d.ts +13 -6
  143. package/dist/telegram.d.ts.map +1 -1
  144. package/dist/telegram.js +43 -14
  145. package/dist/telegram.js.map +1 -1
  146. package/dist/tools/defs/memory-defs.d.ts.map +1 -1
  147. package/dist/tools/defs/memory-defs.js +0 -19
  148. package/dist/tools/defs/memory-defs.js.map +1 -1
  149. package/dist/tools/delegate-tool.d.ts +4 -0
  150. package/dist/tools/delegate-tool.d.ts.map +1 -1
  151. package/dist/tools/delegate-tool.js +48 -109
  152. package/dist/tools/delegate-tool.js.map +1 -1
  153. package/dist/tools/memory-tools.d.ts.map +1 -1
  154. package/dist/tools/memory-tools.js +1 -16
  155. package/dist/tools/memory-tools.js.map +1 -1
  156. package/dist/tools/shared-agent-utils.d.ts +9 -1
  157. package/dist/tools/shared-agent-utils.d.ts.map +1 -1
  158. package/dist/tools/shared-agent-utils.js +24 -42
  159. package/dist/tools/shared-agent-utils.js.map +1 -1
  160. package/dist/tools/start-session-tool.d.ts +2 -0
  161. package/dist/tools/start-session-tool.d.ts.map +1 -1
  162. package/dist/tools/start-session-tool.js +66 -106
  163. package/dist/tools/start-session-tool.js.map +1 -1
  164. package/dist/tools/thread-lifecycle.d.ts +5 -127
  165. package/dist/tools/thread-lifecycle.d.ts.map +1 -1
  166. package/dist/tools/thread-lifecycle.js +5 -1163
  167. package/dist/tools/thread-lifecycle.js.map +1 -1
  168. package/dist/tools/utility-tools.js +5 -2
  169. package/dist/tools/utility-tools.js.map +1 -1
  170. package/dist/tools/wait/drive-handler.d.ts +0 -1
  171. package/dist/tools/wait/drive-handler.d.ts.map +1 -1
  172. package/dist/tools/wait/drive-handler.js +5 -22
  173. package/dist/tools/wait/drive-handler.js.map +1 -1
  174. package/dist/tools/wait/message-delivery.js +1 -1
  175. package/dist/tools/wait/message-delivery.js.map +1 -1
  176. package/dist/tools/wait/message-processing.d.ts.map +1 -1
  177. package/dist/tools/wait/message-processing.js +9 -8
  178. package/dist/tools/wait/message-processing.js.map +1 -1
  179. package/dist/tools/wait/poll-loop.d.ts +2 -0
  180. package/dist/tools/wait/poll-loop.d.ts.map +1 -1
  181. package/dist/tools/wait/poll-loop.js +27 -29
  182. package/dist/tools/wait/poll-loop.js.map +1 -1
  183. package/dist/tools/wait/task-handler.d.ts +0 -3
  184. package/dist/tools/wait/task-handler.d.ts.map +1 -1
  185. package/dist/tools/wait/task-handler.js +3 -2
  186. package/dist/tools/wait/task-handler.js.map +1 -1
  187. package/dist/types.d.ts +0 -1
  188. package/dist/types.d.ts.map +1 -1
  189. package/package.json +4 -8
  190. package/supervisor/config.go +182 -69
  191. package/supervisor/config_test.go +78 -0
  192. package/supervisor/go.mod +12 -0
  193. package/supervisor/go.sum +20 -0
  194. package/supervisor/health.go +60 -11
  195. package/supervisor/health_test.go +29 -0
  196. package/supervisor/keeper.go +15 -10
  197. package/supervisor/log.go +109 -28
  198. package/supervisor/log_test.go +86 -6
  199. package/supervisor/main.go +150 -19
  200. package/supervisor/main_test.go +130 -0
  201. package/supervisor/process.go +47 -4
  202. package/supervisor/process_test.go +14 -0
  203. package/supervisor/secrets.go +95 -0
  204. package/supervisor/secrets_securevault_test.go +98 -0
  205. package/supervisor/secrets_test.go +119 -0
  206. package/supervisor/self_update.go +282 -0
  207. package/supervisor/self_update_test.go +177 -0
  208. package/supervisor/service_restart_stub.go +9 -0
  209. package/supervisor/service_restart_windows.go +63 -0
  210. package/supervisor/service_stub.go +15 -0
  211. package/supervisor/service_windows.go +216 -0
  212. package/supervisor/update_state.go +264 -0
  213. package/supervisor/update_state_test.go +306 -0
  214. package/supervisor/updater.go +311 -10
  215. package/supervisor/updater_test.go +64 -0
  216. package/dist/data/memory/quality-scoring.d.ts +0 -32
  217. package/dist/data/memory/quality-scoring.d.ts.map +0 -1
  218. package/dist/data/memory/quality-scoring.js +0 -182
  219. package/dist/data/memory/quality-scoring.js.map +0 -1
  220. package/scripts/install-supervisor.ps1 +0 -67
  221. package/scripts/install-supervisor.sh +0 -43
  222. package/scripts/start-supervisor.ps1 +0 -46
  223. package/scripts/start-supervisor.sh +0 -20
  224. package/templates/coding-task.default.md +0 -12
@@ -0,0 +1,119 @@
1
+ package main
2
+
3
+ import (
4
+ "errors"
5
+ "testing"
6
+
7
+ "github.com/zalando/go-keyring"
8
+ )
9
+
10
+ func TestResolveSecretWithKeyring_EnvWins(t *testing.T) {
11
+ t.Setenv("MCP_HTTP_SECRET", "env-secret")
12
+
13
+ orig := keyringGet
14
+ keyringGet = func(service, user string) (string, error) {
15
+ return "keyring-secret", nil
16
+ }
17
+ t.Cleanup(func() { keyringGet = orig })
18
+
19
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
20
+ if got != "env-secret" {
21
+ t.Fatalf("resolveSecretWithKeyring() = %q, want %q", got, "env-secret")
22
+ }
23
+ }
24
+
25
+ func TestResolveSecretWithKeyring_FallbackToKeyring(t *testing.T) {
26
+ t.Setenv("MCP_HTTP_SECRET", "")
27
+
28
+ orig := keyringGet
29
+ keyringGet = func(service, user string) (string, error) {
30
+ if service != "sensorium-supervisor" {
31
+ t.Fatalf("service = %q, want %q", service, "sensorium-supervisor")
32
+ }
33
+ if user != "MCP_HTTP_SECRET" {
34
+ t.Fatalf("user = %q, want %q", user, "MCP_HTTP_SECRET")
35
+ }
36
+ return "keyring-secret", nil
37
+ }
38
+ t.Cleanup(func() { keyringGet = orig })
39
+
40
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
41
+ if got != "keyring-secret" {
42
+ t.Fatalf("resolveSecretWithKeyring() = %q, want %q", got, "keyring-secret")
43
+ }
44
+ }
45
+
46
+ func TestResolveSecretWithKeyring_NotFound(t *testing.T) {
47
+ t.Setenv("MCP_HTTP_SECRET", "")
48
+
49
+ orig := keyringGet
50
+ keyringGet = func(service, user string) (string, error) {
51
+ return "", keyring.ErrNotFound
52
+ }
53
+ t.Cleanup(func() { keyringGet = orig })
54
+
55
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
56
+ if got != "" {
57
+ t.Fatalf("resolveSecretWithKeyring() = %q, want empty", got)
58
+ }
59
+ }
60
+
61
+ func TestResolveSecretWithKeyring_OtherError(t *testing.T) {
62
+ t.Setenv("MCP_HTTP_SECRET", "")
63
+
64
+ orig := keyringGet
65
+ keyringGet = func(service, user string) (string, error) {
66
+ return "", errors.New("keyring backend unavailable")
67
+ }
68
+ t.Cleanup(func() { keyringGet = orig })
69
+
70
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
71
+ if got != "" {
72
+ t.Fatalf("resolveSecretWithKeyring() = %q, want empty", got)
73
+ }
74
+ }
75
+
76
+ func TestResolveIntWithKeyring_EnvWins(t *testing.T) {
77
+ t.Setenv("MCP_HTTP_PORT", "3847")
78
+
79
+ orig := keyringGet
80
+ keyringGet = func(service, user string) (string, error) {
81
+ return "9999", nil
82
+ }
83
+ t.Cleanup(func() { keyringGet = orig })
84
+
85
+ got := resolveIntWithKeyring("MCP_HTTP_PORT", "sensorium-supervisor", 0)
86
+ if got != 3847 {
87
+ t.Fatalf("resolveIntWithKeyring() = %d, want %d", got, 3847)
88
+ }
89
+ }
90
+
91
+ func TestResolveIntWithKeyring_FallbackToKeyring(t *testing.T) {
92
+ t.Setenv("MCP_HTTP_PORT", "")
93
+
94
+ orig := keyringGet
95
+ keyringGet = func(service, user string) (string, error) {
96
+ return "5001", nil
97
+ }
98
+ t.Cleanup(func() { keyringGet = orig })
99
+
100
+ got := resolveIntWithKeyring("MCP_HTTP_PORT", "sensorium-supervisor", 0)
101
+ if got != 5001 {
102
+ t.Fatalf("resolveIntWithKeyring() = %d, want %d", got, 5001)
103
+ }
104
+ }
105
+
106
+ func TestResolveIntWithKeyring_InvalidFallback(t *testing.T) {
107
+ t.Setenv("MCP_HTTP_PORT", "")
108
+
109
+ orig := keyringGet
110
+ keyringGet = func(service, user string) (string, error) {
111
+ return "not-a-number", nil
112
+ }
113
+ t.Cleanup(func() { keyringGet = orig })
114
+
115
+ got := resolveIntWithKeyring("MCP_HTTP_PORT", "sensorium-supervisor", 3847)
116
+ if got != 3847 {
117
+ t.Fatalf("resolveIntWithKeyring() = %d, want fallback %d", got, 3847)
118
+ }
119
+ }
@@ -0,0 +1,282 @@
1
+ package main
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "os"
7
+ "os/exec"
8
+ "path/filepath"
9
+ "runtime"
10
+ "strings"
11
+ )
12
+
13
+ const supervisorRollbackAttemptedMarker = "rollback_attempted=true"
14
+ const supervisorWindowsServiceName = "SensoriumSupervisor"
15
+
16
+ // applyPendingSupervisorUpdate applies a downloaded supervisor binary before the
17
+ // rest of the process starts. On Windows, a detached helper performs the swap
18
+ // after this bootstrap process exits because a running .exe cannot overwrite
19
+ // itself in place.
20
+ func applyPendingSupervisorUpdate(cfg Config, log *Logger) (bool, error) {
21
+ recordPendingSupervisorApplyFailureIfPresent(cfg, log)
22
+
23
+ if _, err := os.Stat(cfg.Paths.PendingBinary); err != nil {
24
+ if errors.Is(err, os.ErrNotExist) {
25
+ if _, versionErr := os.Stat(cfg.Paths.PendingVersion); versionErr == nil {
26
+ log.Warn("Removing stale pending supervisor version file %s", cfg.Paths.PendingVersion)
27
+ _ = os.Remove(cfg.Paths.PendingVersion)
28
+ }
29
+ return false, nil
30
+ }
31
+ return false, fmt.Errorf("stat pending supervisor binary: %w", err)
32
+ }
33
+
34
+ exePath, err := os.Executable()
35
+ if err != nil {
36
+ cleanupPendingSupervisorUpdate(cfg, log)
37
+ return false, fmt.Errorf("resolve current executable: %w", err)
38
+ }
39
+
40
+ if runtime.GOOS == "windows" {
41
+ isService, serviceErr := isWindowsService()
42
+ if serviceErr != nil {
43
+ markSupervisorApplyFailure(cfg, log, fmt.Sprintf("detect service mode for pending supervisor apply: %v", serviceErr))
44
+ cleanupPendingSupervisorUpdate(cfg, log)
45
+ return false, fmt.Errorf("detect service mode for pending supervisor apply: %w", serviceErr)
46
+ }
47
+
48
+ if err := launchWindowsApplyHelper(cfg, exePath, isService); err != nil {
49
+ markSupervisorApplyFailure(cfg, log, fmt.Sprintf("schedule pending supervisor apply: %v", err))
50
+ cleanupPendingSupervisorUpdate(cfg, log)
51
+ return false, fmt.Errorf("schedule pending supervisor apply: %w", err)
52
+ }
53
+ log.Info("Pending supervisor update detected; restarting to apply")
54
+ return true, nil
55
+ }
56
+
57
+ if err := os.Rename(cfg.Paths.PendingBinary, exePath); err != nil {
58
+ cleanupPendingSupervisorUpdate(cfg, log)
59
+ return false, fmt.Errorf("apply pending supervisor binary: %w", err)
60
+ }
61
+
62
+ if err := finalizePendingSupervisorVersion(cfg); err != nil {
63
+ log.Warn("Applied supervisor update but failed to persist version: %v", err)
64
+ } else {
65
+ log.Info("Applied supervisor update")
66
+ }
67
+
68
+ return false, nil
69
+ }
70
+
71
+ func finalizePendingSupervisorVersion(cfg Config) error {
72
+ data, err := os.ReadFile(cfg.Paths.PendingVersion)
73
+ if err != nil {
74
+ if errors.Is(err, os.ErrNotExist) {
75
+ return nil
76
+ }
77
+ return fmt.Errorf("read pending supervisor version: %w", err)
78
+ }
79
+
80
+ if err := atomicWrite(cfg.Paths.SupervisorVersion, data); err != nil {
81
+ return fmt.Errorf("write supervisor version: %w", err)
82
+ }
83
+ if err := os.Remove(cfg.Paths.PendingVersion); err != nil && !errors.Is(err, os.ErrNotExist) {
84
+ return fmt.Errorf("remove pending supervisor version: %w", err)
85
+ }
86
+ return nil
87
+ }
88
+
89
+ func cleanupPendingSupervisorUpdate(cfg Config, log *Logger) {
90
+ for _, path := range []string{cfg.Paths.PendingBinary, cfg.Paths.PendingVersion} {
91
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
92
+ log.Warn("Failed to remove stale supervisor update artifact %s: %v", path, err)
93
+ }
94
+ }
95
+ }
96
+
97
+ func launchWindowsApplyHelper(cfg Config, exePath string, restartViaService bool) error {
98
+ if err := os.MkdirAll(filepath.Dir(cfg.Paths.SupervisorVersion), 0755); err != nil {
99
+ return fmt.Errorf("create supervisor version directory: %w", err)
100
+ }
101
+
102
+ scriptFile, err := os.CreateTemp("", "sensorium-supervisor-apply-*.cmd")
103
+ if err != nil {
104
+ return fmt.Errorf("create apply helper script: %w", err)
105
+ }
106
+
107
+ script := buildWindowsApplyHelperScript(cfg, exePath, restartViaService)
108
+
109
+ if _, err := scriptFile.WriteString(script); err != nil {
110
+ scriptFile.Close()
111
+ _ = os.Remove(scriptFile.Name())
112
+ return fmt.Errorf("write apply helper script: %w", err)
113
+ }
114
+ if err := scriptFile.Close(); err != nil {
115
+ _ = os.Remove(scriptFile.Name())
116
+ return fmt.Errorf("close apply helper script: %w", err)
117
+ }
118
+
119
+ cmd := exec.Command("cmd", "/c", scriptFile.Name())
120
+ cmd.Env = os.Environ()
121
+ cmd.Stdin = nil
122
+ cmd.Stdout = nil
123
+ cmd.Stderr = nil
124
+ setSysProcAttr(cmd)
125
+
126
+ if err := cmd.Start(); err != nil {
127
+ _ = os.Remove(scriptFile.Name())
128
+ return fmt.Errorf("start apply helper: %w", err)
129
+ }
130
+
131
+ _ = cmd.Process.Release()
132
+ return nil
133
+ }
134
+
135
+ func buildWindowsApplyHelperScript(cfg Config, exePath string, restartViaService bool) string {
136
+ failReason := fmt.Sprintf("helper failed to swap pending supervisor binary after retries (pending=%s current=%s)", cfg.Paths.PendingBinary, exePath)
137
+ escapedFailReason := batchEscapeForSetValue(failReason)
138
+
139
+ failLines := []string{
140
+ fmt.Sprintf(`set "FAIL_REASON=%s"`, escapedFailReason),
141
+ fmt.Sprintf(`<nul set /p "=%%FAIL_REASON%%" > %s`, batchQuote(supervisorApplyFailureMarkerPath(cfg))),
142
+ fmt.Sprintf(`if exist %s del /F /Q %s`, batchQuote(cfg.Paths.PendingBinary), batchQuote(cfg.Paths.PendingBinary)),
143
+ fmt.Sprintf(`if exist %s del /F /Q %s`, batchQuote(cfg.Paths.PendingVersion), batchQuote(cfg.Paths.PendingVersion)),
144
+ }
145
+
146
+ if restartViaService {
147
+ failLines = append(failLines,
148
+ fmt.Sprintf(`sc start %s >NUL 2>&1`, batchQuote(supervisorWindowsServiceName)),
149
+ "if errorlevel 1 (",
150
+ " timeout /T 2 /NOBREAK >NUL",
151
+ fmt.Sprintf(` sc start %s >NUL 2>&1`, batchQuote(supervisorWindowsServiceName)),
152
+ ")",
153
+ )
154
+ } else {
155
+ failLines = append(failLines, fmt.Sprintf(`start "" %s`, batchQuote(exePath)))
156
+ }
157
+
158
+ failLines = append(failLines, "exit /b 1")
159
+
160
+ scriptLines := []string{
161
+ "@echo off",
162
+ "setlocal",
163
+ ":wait",
164
+ fmt.Sprintf(`tasklist /FI "PID eq %d" 2>NUL | find "%d" >NUL`, os.Getpid(), os.Getpid()),
165
+ "if not errorlevel 1 (",
166
+ " timeout /T 1 /NOBREAK >NUL",
167
+ " goto wait",
168
+ ")",
169
+ "set attempts=0",
170
+ ":move",
171
+ fmt.Sprintf(`move /Y %s %s >NUL`, batchQuote(cfg.Paths.PendingBinary), batchQuote(exePath)),
172
+ "if not errorlevel 1 goto applied",
173
+ "set /a attempts+=1",
174
+ "if %attempts% GEQ 5 goto fail",
175
+ "timeout /T 1 /NOBREAK >NUL",
176
+ "goto move",
177
+ ":applied",
178
+ fmt.Sprintf(`if exist %s move /Y %s %s >NUL`, batchQuote(cfg.Paths.PendingVersion), batchQuote(cfg.Paths.PendingVersion), batchQuote(cfg.Paths.SupervisorVersion)),
179
+ fmt.Sprintf(`if exist %s del /F /Q %s`, batchQuote(supervisorApplyFailureMarkerPath(cfg)), batchQuote(supervisorApplyFailureMarkerPath(cfg))),
180
+ }
181
+
182
+ if !restartViaService {
183
+ scriptLines = append(scriptLines, fmt.Sprintf(`start "" %s`, batchQuote(exePath)))
184
+ }
185
+
186
+ scriptLines = append(scriptLines,
187
+ "exit /b 0",
188
+ ":fail",
189
+ )
190
+
191
+ scriptLines = append(scriptLines, failLines...)
192
+ scriptLines = append(scriptLines, "")
193
+
194
+ return strings.Join(scriptLines, "\r\n")
195
+ }
196
+
197
+ func supervisorApplyFailureMarkerPath(cfg Config) string {
198
+ return cfg.Paths.PendingBinary + ".failed"
199
+ }
200
+
201
+ func recordPendingSupervisorApplyFailureIfPresent(cfg Config, log *Logger) {
202
+ markerPath := supervisorApplyFailureMarkerPath(cfg)
203
+ data, err := os.ReadFile(markerPath)
204
+ if err != nil {
205
+ if !errors.Is(err, os.ErrNotExist) {
206
+ log.Warn("Failed to read supervisor apply failure marker %s: %v", markerPath, err)
207
+ }
208
+ return
209
+ }
210
+
211
+ reason := strings.TrimSpace(string(data))
212
+ if reason == "" {
213
+ reason = "pending supervisor apply helper reported failure"
214
+ }
215
+
216
+ store := NewUpdateStateStore(cfg.Paths.UpdateState, log)
217
+ state, stateErr := store.Load()
218
+ if stateErr != nil {
219
+ log.Warn("Failed to load update state while reconciling supervisor apply failure marker: %v", stateErr)
220
+ }
221
+ targetVersion := strings.TrimSpace(state.TargetVersion)
222
+ if targetVersion == "" {
223
+ targetVersion = strings.TrimSpace(readTrimmedFile(cfg.Paths.PendingVersion))
224
+ }
225
+ if targetVersion != "" {
226
+ currentVersion := strings.TrimSpace(readTrimmedFile(cfg.Paths.SupervisorVersion))
227
+ if currentVersion == targetVersion {
228
+ log.Warn("Ignoring stale supervisor apply failure marker because supervisor version already matches target %s", targetVersion)
229
+ store.Transition(updateScopeSupervisor, updatePhaseIdle, targetVersion, state.PreviousVersion, "")
230
+ if err := os.Remove(markerPath); err != nil && !errors.Is(err, os.ErrNotExist) {
231
+ log.Warn("Failed to remove stale supervisor apply failure marker %s: %v", markerPath, err)
232
+ }
233
+ return
234
+ }
235
+ }
236
+
237
+ markSupervisorApplyFailure(cfg, log, reason)
238
+
239
+ if err := os.Remove(markerPath); err != nil && !errors.Is(err, os.ErrNotExist) {
240
+ log.Warn("Failed to remove supervisor apply failure marker %s: %v", markerPath, err)
241
+ }
242
+ }
243
+
244
+ func markSupervisorApplyFailure(cfg Config, log *Logger, reason string) {
245
+ store := NewUpdateStateStore(cfg.Paths.UpdateState, log)
246
+ state, err := store.Load()
247
+ targetVersion := ""
248
+ previousVersion := ""
249
+ if err != nil {
250
+ log.Warn("Failed to load update state while marking supervisor apply failure: %v", err)
251
+ } else {
252
+ targetVersion = state.TargetVersion
253
+ previousVersion = state.PreviousVersion
254
+ }
255
+
256
+ if targetVersion == "" {
257
+ targetVersion = strings.TrimSpace(readTrimmedFile(cfg.Paths.PendingVersion))
258
+ }
259
+ if previousVersion == "" {
260
+ previousVersion = strings.TrimSpace(readTrimmedFile(cfg.Paths.SupervisorVersion))
261
+ }
262
+
263
+ lastError := reason
264
+ if !strings.Contains(lastError, supervisorRollbackAttemptedMarker) {
265
+ lastError = strings.TrimSpace(lastError + "; " + supervisorRollbackAttemptedMarker)
266
+ }
267
+
268
+ store.Transition(updateScopeSupervisor, updatePhaseRollback, targetVersion, previousVersion, lastError)
269
+ store.Transition(updateScopeSupervisor, updatePhaseFailed, targetVersion, previousVersion, lastError)
270
+ }
271
+
272
+ func batchQuote(path string) string {
273
+ return `"` + strings.ReplaceAll(path, `"`, `""`) + `"`
274
+ }
275
+
276
+ func batchEscapeForSetValue(value string) string {
277
+ value = strings.ReplaceAll(value, "\r", " ")
278
+ value = strings.ReplaceAll(value, "\n", " ")
279
+ value = strings.ReplaceAll(value, `%`, `%%`)
280
+ value = strings.ReplaceAll(value, `"`, `'`)
281
+ return value
282
+ }
@@ -0,0 +1,177 @@
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestApplyPendingSupervisorUpdate_ConvergesToFailedFromRollbackMarker(t *testing.T) {
11
+ dir := t.TempDir()
12
+ log := NewLogger(filepath.Join(dir, "test.log"))
13
+ defer log.Close()
14
+
15
+ cfg := Config{
16
+ Paths: Paths{
17
+ PendingBinary: filepath.Join(dir, "bin", "sensorium-supervisor.new.exe"),
18
+ PendingVersion: filepath.Join(dir, "bin", "sensorium-supervisor.new.exe.version"),
19
+ SupervisorVersion: filepath.Join(dir, "supervisor-version.txt"),
20
+ UpdateState: filepath.Join(dir, "update-state.json"),
21
+ },
22
+ }
23
+
24
+ if err := os.MkdirAll(filepath.Dir(cfg.Paths.PendingBinary), 0755); err != nil {
25
+ t.Fatalf("create pending dir: %v", err)
26
+ }
27
+
28
+ store := NewUpdateStateStore(cfg.Paths.UpdateState, log)
29
+ store.Transition(updateScopeSupervisor, updatePhaseRestarting, "2.0.0", "1.0.0", "")
30
+
31
+ reason := "helper failed to swap pending supervisor binary after retries"
32
+ if err := os.WriteFile(supervisorApplyFailureMarkerPath(cfg), []byte(reason), 0644); err != nil {
33
+ t.Fatalf("write apply failure marker: %v", err)
34
+ }
35
+
36
+ shouldRestart, err := applyPendingSupervisorUpdate(cfg, log)
37
+ if err != nil {
38
+ t.Fatalf("applyPendingSupervisorUpdate() error = %v", err)
39
+ }
40
+ if shouldRestart {
41
+ t.Fatal("applyPendingSupervisorUpdate() shouldRestart = true, want false")
42
+ }
43
+
44
+ state, err := store.Load()
45
+ if err != nil {
46
+ t.Fatalf("load state: %v", err)
47
+ }
48
+ if state.Scope != updateScopeSupervisor {
49
+ t.Fatalf("scope = %q, want %q", state.Scope, updateScopeSupervisor)
50
+ }
51
+ if state.Phase != updatePhaseFailed {
52
+ t.Fatalf("phase = %q, want %q", state.Phase, updatePhaseFailed)
53
+ }
54
+ if !strings.Contains(state.LastError, reason) {
55
+ t.Fatalf("last error = %q, want reason %q", state.LastError, reason)
56
+ }
57
+ if !strings.Contains(state.LastError, supervisorRollbackAttemptedMarker) {
58
+ t.Fatalf("last error = %q, missing rollback marker %q", state.LastError, supervisorRollbackAttemptedMarker)
59
+ }
60
+
61
+ if _, statErr := os.Stat(supervisorApplyFailureMarkerPath(cfg)); !os.IsNotExist(statErr) {
62
+ t.Fatalf("expected apply failure marker to be removed, got: %v", statErr)
63
+ }
64
+ }
65
+
66
+ func TestBuildWindowsApplyHelperScript_TaskModeFailPathRestartsCurrentBinary(t *testing.T) {
67
+ cfg := Config{
68
+ Paths: Paths{
69
+ PendingBinary: `C:\data\bin\sensorium-supervisor.new.exe`,
70
+ PendingVersion: `C:\data\bin\sensorium-supervisor.new.exe.version`,
71
+ SupervisorVersion: `C:\data\supervisor-version.txt`,
72
+ },
73
+ }
74
+ exePath := `C:\data\sensorium-supervisor.exe`
75
+
76
+ script := buildWindowsApplyHelperScript(cfg, exePath, false)
77
+
78
+ failStart := `:fail` + "\r\n" + `set "FAIL_REASON=helper failed to swap pending supervisor binary after retries (pending=C:\data\bin\sensorium-supervisor.new.exe current=C:\data\sensorium-supervisor.exe)"` + "\r\n" + `<nul set /p "=%FAIL_REASON%" > "C:\data\bin\sensorium-supervisor.new.exe.failed"` + "\r\n" + `if exist "C:\data\bin\sensorium-supervisor.new.exe" del /F /Q "C:\data\bin\sensorium-supervisor.new.exe"` + "\r\n" + `if exist "C:\data\bin\sensorium-supervisor.new.exe.version" del /F /Q "C:\data\bin\sensorium-supervisor.new.exe.version"` + "\r\n" + `start "" "C:\data\sensorium-supervisor.exe"`
79
+ if !strings.Contains(script, failStart) {
80
+ t.Fatalf("task-mode fail fallback restart block missing\nscript:\n%s", script)
81
+ }
82
+
83
+ if !strings.Contains(script, `:applied`+"\r\n"+`if exist "C:\data\bin\sensorium-supervisor.new.exe.version" move /Y "C:\data\bin\sensorium-supervisor.new.exe.version" "C:\data\supervisor-version.txt" >NUL`+"\r\n"+`if exist "C:\data\bin\sensorium-supervisor.new.exe.failed" del /F /Q "C:\data\bin\sensorium-supervisor.new.exe.failed"`+"\r\n"+`start "" "C:\data\sensorium-supervisor.exe"`) {
84
+ t.Fatalf("task-mode applied restart block missing\nscript:\n%s", script)
85
+ }
86
+ }
87
+
88
+ func TestBuildWindowsApplyHelperScript_ServiceModeDoesNotStartBinary(t *testing.T) {
89
+ cfg := Config{
90
+ Paths: Paths{
91
+ PendingBinary: `C:\data\bin\sensorium-supervisor.new.exe`,
92
+ PendingVersion: `C:\data\bin\sensorium-supervisor.new.exe.version`,
93
+ SupervisorVersion: `C:\data\supervisor-version.txt`,
94
+ },
95
+ }
96
+ exePath := `C:\data\sensorium-supervisor.exe`
97
+
98
+ script := buildWindowsApplyHelperScript(cfg, exePath, true)
99
+ if strings.Contains(script, `start "" "C:\data\sensorium-supervisor.exe"`) {
100
+ t.Fatalf("service-mode helper unexpectedly starts supervisor binary\nscript:\n%s", script)
101
+ }
102
+
103
+ serviceFailRestart := `:fail` + "\r\n" + `set "FAIL_REASON=helper failed to swap pending supervisor binary after retries (pending=C:\data\bin\sensorium-supervisor.new.exe current=C:\data\sensorium-supervisor.exe)"` + "\r\n" + `<nul set /p "=%FAIL_REASON%" > "C:\data\bin\sensorium-supervisor.new.exe.failed"` + "\r\n" + `if exist "C:\data\bin\sensorium-supervisor.new.exe" del /F /Q "C:\data\bin\sensorium-supervisor.new.exe"` + "\r\n" + `if exist "C:\data\bin\sensorium-supervisor.new.exe.version" del /F /Q "C:\data\bin\sensorium-supervisor.new.exe.version"` + "\r\n" + `sc start "SensoriumSupervisor" >NUL 2>&1` + "\r\n" + `if errorlevel 1 (` + "\r\n" + ` timeout /T 2 /NOBREAK >NUL` + "\r\n" + ` sc start "SensoriumSupervisor" >NUL 2>&1` + "\r\n" + `)`
104
+ if !strings.Contains(script, serviceFailRestart) {
105
+ t.Fatalf("service-mode fail recovery assist block missing\nscript:\n%s", script)
106
+ }
107
+ }
108
+
109
+ func TestBuildWindowsApplyHelperScript_EscapesFailReasonForCmdSafety(t *testing.T) {
110
+ cfg := Config{
111
+ Paths: Paths{
112
+ PendingBinary: `C:\data\bin\sensorium&supervisor.new%TMP%.exe`,
113
+ PendingVersion: `C:\data\bin\sensorium-supervisor.new.exe.version`,
114
+ SupervisorVersion: `C:\data\supervisor-version.txt`,
115
+ },
116
+ }
117
+ exePath := `C:\data\sensorium"supervisor.exe`
118
+
119
+ script := buildWindowsApplyHelperScript(cfg, exePath, false)
120
+
121
+ if !strings.Contains(script, `set "FAIL_REASON=helper failed to swap pending supervisor binary after retries (pending=C:\data\bin\sensorium&supervisor.new%%TMP%%.exe current=C:\data\sensorium'supervisor.exe)"`) {
122
+ t.Fatalf("expected escaped quoted FAIL_REASON assignment\nscript:\n%s", script)
123
+ }
124
+ if strings.Contains(script, `set FAIL_REASON=`) {
125
+ t.Fatalf("found unsafe unquoted FAIL_REASON assignment\nscript:\n%s", script)
126
+ }
127
+ }
128
+
129
+ func TestRecordPendingSupervisorApplyFailureIfPresent_IgnoresStaleMarkerWhenTargetAlreadyApplied(t *testing.T) {
130
+ dir := t.TempDir()
131
+ log := NewLogger(filepath.Join(dir, "test.log"))
132
+ defer log.Close()
133
+
134
+ targetVersion := "2.0.0"
135
+ cfg := Config{
136
+ Paths: Paths{
137
+ PendingBinary: filepath.Join(dir, "bin", "sensorium-supervisor.new.exe"),
138
+ PendingVersion: filepath.Join(dir, "bin", "sensorium-supervisor.new.exe.version"),
139
+ SupervisorVersion: filepath.Join(dir, "supervisor-version.txt"),
140
+ UpdateState: filepath.Join(dir, "update-state.json"),
141
+ },
142
+ }
143
+
144
+ if err := os.MkdirAll(filepath.Dir(cfg.Paths.PendingBinary), 0755); err != nil {
145
+ t.Fatalf("create pending dir: %v", err)
146
+ }
147
+ if err := os.WriteFile(cfg.Paths.SupervisorVersion, []byte(targetVersion), 0644); err != nil {
148
+ t.Fatalf("write supervisor version: %v", err)
149
+ }
150
+
151
+ store := NewUpdateStateStore(cfg.Paths.UpdateState, log)
152
+ store.Transition(updateScopeSupervisor, updatePhaseFailed, targetVersion, "1.0.0", "old failure")
153
+
154
+ if err := os.WriteFile(supervisorApplyFailureMarkerPath(cfg), []byte("stale helper failure"), 0644); err != nil {
155
+ t.Fatalf("write apply failure marker: %v", err)
156
+ }
157
+
158
+ recordPendingSupervisorApplyFailureIfPresent(cfg, log)
159
+
160
+ state, err := store.Load()
161
+ if err != nil {
162
+ t.Fatalf("load state: %v", err)
163
+ }
164
+ if state.Phase != updatePhaseIdle {
165
+ t.Fatalf("phase = %q, want %q", state.Phase, updatePhaseIdle)
166
+ }
167
+ if state.TargetVersion != targetVersion {
168
+ t.Fatalf("target = %q, want %q", state.TargetVersion, targetVersion)
169
+ }
170
+ if state.LastError != "" {
171
+ t.Fatalf("last error = %q, want empty", state.LastError)
172
+ }
173
+
174
+ if _, statErr := os.Stat(supervisorApplyFailureMarkerPath(cfg)); !os.IsNotExist(statErr) {
175
+ t.Fatalf("expected apply failure marker to be removed, got: %v", statErr)
176
+ }
177
+ }
@@ -0,0 +1,9 @@
1
+ //go:build !windows
2
+
3
+ package main
4
+
5
+ import "errors"
6
+
7
+ func scheduleServiceRestartForUpdate(_ *Logger) error {
8
+ return errors.New("not supported on this OS")
9
+ }
@@ -0,0 +1,63 @@
1
+ //go:build windows
2
+
3
+ package main
4
+
5
+ import (
6
+ "fmt"
7
+ "os"
8
+ "os/exec"
9
+ "strings"
10
+ )
11
+
12
+ func scheduleServiceRestartForUpdate(log *Logger) error {
13
+ scriptFile, err := os.CreateTemp("", "sensorium-supervisor-service-restart-*.cmd")
14
+ if err != nil {
15
+ return fmt.Errorf("create service restart helper: %w", err)
16
+ }
17
+
18
+ script := strings.Join([]string{
19
+ "@echo off",
20
+ "setlocal",
21
+ fmt.Sprintf(`sc stop %s >NUL 2>&1`, batchQuote(serviceName)),
22
+ "timeout /T 2 /NOBREAK >NUL",
23
+ "set attempts=0",
24
+ ":waitStopped",
25
+ fmt.Sprintf(`sc query %s | find "STATE" | find "STOPPED" >NUL`, batchQuote(serviceName)),
26
+ "if not errorlevel 1 goto start",
27
+ "set /a attempts+=1",
28
+ "if %attempts% GEQ 10 goto start",
29
+ "timeout /T 1 /NOBREAK >NUL",
30
+ "goto waitStopped",
31
+ ":start",
32
+ "timeout /T 3 /NOBREAK >NUL",
33
+ fmt.Sprintf(`sc start %s >NUL 2>&1`, batchQuote(serviceName)),
34
+ "exit /b 0",
35
+ "",
36
+ }, "\r\n")
37
+
38
+ if _, err := scriptFile.WriteString(script); err != nil {
39
+ scriptFile.Close()
40
+ _ = os.Remove(scriptFile.Name())
41
+ return fmt.Errorf("write service restart helper: %w", err)
42
+ }
43
+ if err := scriptFile.Close(); err != nil {
44
+ _ = os.Remove(scriptFile.Name())
45
+ return fmt.Errorf("close service restart helper: %w", err)
46
+ }
47
+
48
+ cmd := exec.Command("cmd", "/c", scriptFile.Name())
49
+ cmd.Env = os.Environ()
50
+ cmd.Stdin = nil
51
+ cmd.Stdout = nil
52
+ cmd.Stderr = nil
53
+ setSysProcAttr(cmd)
54
+
55
+ if err := cmd.Start(); err != nil {
56
+ _ = os.Remove(scriptFile.Name())
57
+ return fmt.Errorf("start service restart helper: %w", err)
58
+ }
59
+
60
+ _ = cmd.Process.Release()
61
+ log.Info("Scheduled detached service restart helper to apply pending supervisor update")
62
+ return nil
63
+ }
@@ -0,0 +1,15 @@
1
+ //go:build !windows
2
+
3
+ package main
4
+
5
+ import "errors"
6
+
7
+ func runAsService() error { return errors.New("not supported on this OS") }
8
+ func installService(_, _, _ string) error {
9
+ return errors.New("not supported on this OS")
10
+ }
11
+ func uninstallService() error { return errors.New("not supported on this OS") }
12
+ func startService() error { return errors.New("not supported on this OS") }
13
+ func stopService() error { return errors.New("not supported on this OS") }
14
+ func serviceStatus() error { return errors.New("not supported on this OS") }
15
+ func isWindowsService() (bool, error) { return false, nil }