@plainconceptsplatform/loop-task 2.6.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 (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +534 -0
  3. package/dist/app/App.js +132 -0
  4. package/dist/app/BoardLayout.js +5 -0
  5. package/dist/app/index.js +48 -0
  6. package/dist/app/providers/index.js +1 -0
  7. package/dist/app/router/index.js +16 -0
  8. package/dist/app/types.js +1 -0
  9. package/dist/cli/import-validator.js +152 -0
  10. package/dist/cli/import-writer.js +81 -0
  11. package/dist/cli.js +573 -0
  12. package/dist/client/cli-format.js +7 -0
  13. package/dist/client/commands.js +162 -0
  14. package/dist/client/ipc.js +96 -0
  15. package/dist/client/project-commands.js +164 -0
  16. package/dist/core/command/command-runner.js +300 -0
  17. package/dist/core/command/process-tree.js +73 -0
  18. package/dist/core/command/resolve-cwd.js +8 -0
  19. package/dist/core/command/stdout-capture-transform.js +73 -0
  20. package/dist/core/context/context-parser.js +65 -0
  21. package/dist/core/context/log-context.js +3 -0
  22. package/dist/core/context/template.js +20 -0
  23. package/dist/core/context/validate-context.js +19 -0
  24. package/dist/core/foreground/index.js +81 -0
  25. package/dist/core/logging/bounded-log-reader.js +368 -0
  26. package/dist/core/logging/log-follower.js +26 -0
  27. package/dist/core/logging/log-parser.js +29 -0
  28. package/dist/core/logging/log-rotator.js +34 -0
  29. package/dist/core/logging/rotating-log-stream.js +137 -0
  30. package/dist/core/loop/chain-executor.js +171 -0
  31. package/dist/core/loop/delay-utils.js +65 -0
  32. package/dist/core/loop/loop-controller.js +240 -0
  33. package/dist/core/loop/loop-runner.js +194 -0
  34. package/dist/core/loop/run-executor.js +144 -0
  35. package/dist/core/loop/types.js +1 -0
  36. package/dist/core/scheduling/index.js +16 -0
  37. package/dist/daemon/daemon-log.js +11 -0
  38. package/dist/daemon/diagnostics.js +85 -0
  39. package/dist/daemon/http/helpers.js +81 -0
  40. package/dist/daemon/http/openapi.js +119 -0
  41. package/dist/daemon/http/route-loops.js +311 -0
  42. package/dist/daemon/http/route-misc.js +60 -0
  43. package/dist/daemon/http/route-projects.js +55 -0
  44. package/dist/daemon/http/route-tasks.js +141 -0
  45. package/dist/daemon/http/routes.js +19 -0
  46. package/dist/daemon/http/server.js +82 -0
  47. package/dist/daemon/http/sse.js +36 -0
  48. package/dist/daemon/index.js +189 -0
  49. package/dist/daemon/ipc/send.js +5 -0
  50. package/dist/daemon/managers/loop-entry.js +27 -0
  51. package/dist/daemon/managers/loop-manager.js +346 -0
  52. package/dist/daemon/managers/loop-options.js +17 -0
  53. package/dist/daemon/managers/loop-serialization.js +53 -0
  54. package/dist/daemon/managers/project-manager.js +170 -0
  55. package/dist/daemon/managers/task-manager.js +94 -0
  56. package/dist/daemon/mcp/index.js +2 -0
  57. package/dist/daemon/mcp/openapi-sync.js +50 -0
  58. package/dist/daemon/mcp/server.js +167 -0
  59. package/dist/daemon/mcp/tools.js +382 -0
  60. package/dist/daemon/recipe/deferred-reload.js +32 -0
  61. package/dist/daemon/recipe/diagram-reader.js +26 -0
  62. package/dist/daemon/recipe/file-writer.js +52 -0
  63. package/dist/daemon/recipe/id-remapper.js +46 -0
  64. package/dist/daemon/recipe/runtime-state.js +23 -0
  65. package/dist/daemon/recipe/scanner.js +165 -0
  66. package/dist/daemon/recipe/task-store.js +33 -0
  67. package/dist/daemon/recipe/validator.js +35 -0
  68. package/dist/daemon/server/handlers/diagnostics-handlers.js +7 -0
  69. package/dist/daemon/server/handlers/index.js +101 -0
  70. package/dist/daemon/server/handlers/log-handlers.js +129 -0
  71. package/dist/daemon/server/handlers/loop-handlers.js +64 -0
  72. package/dist/daemon/server/handlers/project-handlers.js +42 -0
  73. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  74. package/dist/daemon/server/handlers/task-handlers.js +20 -0
  75. package/dist/daemon/server/handlers/telemetry-handlers.js +5 -0
  76. package/dist/daemon/server/index.js +117 -0
  77. package/dist/daemon/settings-manager.js +73 -0
  78. package/dist/daemon/spawner/index.js +204 -0
  79. package/dist/daemon/state/index.js +209 -0
  80. package/dist/daemon/telemetry/agent-integrations/agent-integration.js +1 -0
  81. package/dist/daemon/telemetry/agent-integrations/claude-code-integration.js +55 -0
  82. package/dist/daemon/telemetry/agent-integrations/detect-agent-integration.js +20 -0
  83. package/dist/daemon/telemetry/agent-integrations/index.js +3 -0
  84. package/dist/daemon/telemetry/agent-integrations/opencode-integration.js +72 -0
  85. package/dist/daemon/telemetry/index.js +6 -0
  86. package/dist/daemon/telemetry/noop-telemetry-adapter.js +53 -0
  87. package/dist/daemon/telemetry/open-telemetry-adapter.js +464 -0
  88. package/dist/daemon/telemetry/telemetry-manager.js +147 -0
  89. package/dist/daemon/telemetry/telemetry-redaction.js +67 -0
  90. package/dist/daemon/telemetry/telemetry-types.js +45 -0
  91. package/dist/daemon/telemetry/telemetry.js +1 -0
  92. package/dist/daemon/watcher/index.js +250 -0
  93. package/dist/duration.js +24 -0
  94. package/dist/entities/loops/filters.js +109 -0
  95. package/dist/entities/loops/index.js +1 -0
  96. package/dist/entities/projects/filters.js +69 -0
  97. package/dist/entities/projects/index.js +1 -0
  98. package/dist/entities/tasks/filters.js +41 -0
  99. package/dist/entities/tasks/index.js +1 -0
  100. package/dist/entry.js +16 -0
  101. package/dist/esm-loader.js +39 -0
  102. package/dist/features/chain-editor/ChainEditor.js +116 -0
  103. package/dist/features/chain-editor/mermaidToAscii.js +375 -0
  104. package/dist/features/chain-editor/renderChainDiagram.js +106 -0
  105. package/dist/features/code-editor/CodeEditorModal.js +84 -0
  106. package/dist/features/code-editor/CodeEditorPreview.js +14 -0
  107. package/dist/features/code-editor/useEditorKeyboard.js +150 -0
  108. package/dist/features/code-editor/useModalDimensions.js +9 -0
  109. package/dist/features/commands/commands.js +132 -0
  110. package/dist/features/commands/useCommandHandlers.js +280 -0
  111. package/dist/features/commands/useContextualActions.js +78 -0
  112. package/dist/features/commands/useGlobalShortcuts.js +154 -0
  113. package/dist/features/forms/FormRouter.js +46 -0
  114. package/dist/features/overlays/ContextHelpModal.js +8 -0
  115. package/dist/features/overlays/DiagramModal.js +45 -0
  116. package/dist/features/overlays/ExportModal.js +51 -0
  117. package/dist/features/overlays/HelpGuideModal.js +23 -0
  118. package/dist/features/overlays/HelpModal.js +59 -0
  119. package/dist/features/overlays/OverlayStack.js +10 -0
  120. package/dist/features/overlays/ProjectsModal.js +42 -0
  121. package/dist/features/overlays/TaskPickerModal.js +62 -0
  122. package/dist/features/overlays/WelcomeScreen.js +74 -0
  123. package/dist/features/overlays/useOverlayStack.js +55 -0
  124. package/dist/features/state/useAppState.js +151 -0
  125. package/dist/logger.js +25 -0
  126. package/dist/loop-config.js +152 -0
  127. package/dist/shared/clipboard.js +111 -0
  128. package/dist/shared/config/constants.js +104 -0
  129. package/dist/shared/config/paths.js +67 -0
  130. package/dist/shared/container/index.js +24 -0
  131. package/dist/shared/fs-utils.js +44 -0
  132. package/dist/shared/hooks/useBreakpoint.js +11 -0
  133. package/dist/shared/hooks/useDaemonSettings.js +41 -0
  134. package/dist/shared/hooks/useInject.js +6 -0
  135. package/dist/shared/hooks/useLogStream.js +37 -0
  136. package/dist/shared/hooks/useLoopFormValidation.js +131 -0
  137. package/dist/shared/hooks/useLoopPolling.js +30 -0
  138. package/dist/shared/hooks/useMouseScroll.js +56 -0
  139. package/dist/shared/hooks/useUndoRedo.js +77 -0
  140. package/dist/shared/i18n/en.json +709 -0
  141. package/dist/shared/i18n/index.js +11 -0
  142. package/dist/shared/providers/InversifyProvider.js +6 -0
  143. package/dist/shared/services/export-service.js +43 -0
  144. package/dist/shared/services/log-service.js +28 -0
  145. package/dist/shared/services/loop-service.js +78 -0
  146. package/dist/shared/services/project-service.js +39 -0
  147. package/dist/shared/services/settings-service.js +102 -0
  148. package/dist/shared/services/task-service.js +41 -0
  149. package/dist/shared/services/types.js +8 -0
  150. package/dist/shared/sleep.js +17 -0
  151. package/dist/shared/tail.js +4 -0
  152. package/dist/shared/ui/Button.js +18 -0
  153. package/dist/shared/ui/DebugPanel.js +9 -0
  154. package/dist/shared/ui/FocusableButton.js +18 -0
  155. package/dist/shared/ui/FocusableInput.js +72 -0
  156. package/dist/shared/ui/FocusableList.js +48 -0
  157. package/dist/shared/ui/Modal.js +13 -0
  158. package/dist/shared/ui/SelectModal.js +78 -0
  159. package/dist/shared/ui/Toast.js +37 -0
  160. package/dist/shared/ui/format.js +117 -0
  161. package/dist/shared/ui/hooks/useHoverState.js +11 -0
  162. package/dist/shared/ui/state.js +9 -0
  163. package/dist/shared/ui/theme.js +96 -0
  164. package/dist/shared/utils/log-lines.js +13 -0
  165. package/dist/shared/utils/paste.js +37 -0
  166. package/dist/shared/utils/syntax.js +128 -0
  167. package/dist/shared/utils/validation.js +56 -0
  168. package/dist/types.js +1 -0
  169. package/dist/visual-evidence/capture.js +51 -0
  170. package/dist/visual-evidence/cli.js +85 -0
  171. package/dist/visual-evidence/evidence-required.js +71 -0
  172. package/dist/visual-evidence/index.js +10 -0
  173. package/dist/visual-evidence/launch.js +61 -0
  174. package/dist/visual-evidence/manifest.js +32 -0
  175. package/dist/visual-evidence/openspec-resolver.js +45 -0
  176. package/dist/visual-evidence/publish.js +126 -0
  177. package/dist/visual-evidence/run.js +121 -0
  178. package/dist/visual-evidence/scenario-registry.js +132 -0
  179. package/dist/widgets/command-input/CommandDropdown.js +69 -0
  180. package/dist/widgets/command-input/CommandInput.js +133 -0
  181. package/dist/widgets/command-input/HintBar.js +9 -0
  182. package/dist/widgets/command-input/InputModes.js +72 -0
  183. package/dist/widgets/commands-browser/CommandsBrowserModal.js +85 -0
  184. package/dist/widgets/header/Header.js +54 -0
  185. package/dist/widgets/header/TabBar.js +20 -0
  186. package/dist/widgets/left-panel/LeftPanel.js +34 -0
  187. package/dist/widgets/left-panel/Navigator.js +107 -0
  188. package/dist/widgets/left-panel/ProjectsPage.js +76 -0
  189. package/dist/widgets/left-panel/ProjectsPageParts.js +34 -0
  190. package/dist/widgets/left-panel/TaskBrowser.js +132 -0
  191. package/dist/widgets/log-modal/LogModal.js +211 -0
  192. package/dist/widgets/loop-form/CreateForm.js +82 -0
  193. package/dist/widgets/loop-form/TextField.js +9 -0
  194. package/dist/widgets/loop-form/WizardForm.js +199 -0
  195. package/dist/widgets/loop-form/useCreateSteps.js +135 -0
  196. package/dist/widgets/loop-form/useHandleComplete.js +94 -0
  197. package/dist/widgets/project-form/ProjectForm.js +83 -0
  198. package/dist/widgets/right-panel/Inspector.js +41 -0
  199. package/dist/widgets/right-panel/RightPanel.js +38 -0
  200. package/dist/widgets/right-panel/RunHistory.js +187 -0
  201. package/dist/widgets/task-form/TaskForm.js +272 -0
  202. package/package.json +119 -0
@@ -0,0 +1,147 @@
1
+ import { NoopTelemetryAdapter } from "./noop-telemetry-adapter.js";
2
+ import { OpenTelemetryAdapter } from "./open-telemetry-adapter.js";
3
+ import { daemonLog } from "../daemon-log.js";
4
+ /**
5
+ * Manages the lifecycle of the telemetry system.
6
+ * Responds to runtime settings changes by switching between
7
+ * NoopTelemetryAdapter and OpenTelemetryAdapter.
8
+ *
9
+ * This is the single entry point the daemon uses for telemetry.
10
+ */
11
+ export class TelemetryManager {
12
+ constructor(settings) {
13
+ this.settings = settings;
14
+ this.adapter = this.createAdapter(settings);
15
+ }
16
+ createAdapter(settings) {
17
+ if (!settings.telemetryEnabled) {
18
+ return new NoopTelemetryAdapter({
19
+ enabled: false,
20
+ exporterState: "disabled",
21
+ protocol: settings.telemetryProtocol,
22
+ serviceName: settings.telemetryServiceName,
23
+ });
24
+ }
25
+ if (!settings.telemetryEndpoint) {
26
+ // Enabled but no endpoint: no-op that reports "not-configured"
27
+ return new NoopTelemetryAdapter({
28
+ enabled: true,
29
+ exporterConfigured: false,
30
+ exporterState: "not-configured",
31
+ protocol: settings.telemetryProtocol,
32
+ serviceName: settings.telemetryServiceName,
33
+ autoInstrumentAgents: settings.telemetryAutoInstrumentAgents,
34
+ captureContent: settings.telemetryCaptureContent,
35
+ captureCommandOutput: settings.telemetryCaptureCommandOutput,
36
+ });
37
+ }
38
+ return new OpenTelemetryAdapter(settings);
39
+ }
40
+ /**
41
+ * Called when daemon settings change at runtime.
42
+ * May replace the adapter if telemetry settings changed.
43
+ */
44
+ onSettingsChanged(newSettings) {
45
+ const oldSettings = this.settings;
46
+ this.settings = newSettings;
47
+ const telemetryChanged = oldSettings.telemetryEnabled !== newSettings.telemetryEnabled ||
48
+ oldSettings.telemetryEndpoint !== newSettings.telemetryEndpoint ||
49
+ oldSettings.telemetryProtocol !== newSettings.telemetryProtocol ||
50
+ oldSettings.telemetryServiceName !== newSettings.telemetryServiceName;
51
+ if (!telemetryChanged)
52
+ return;
53
+ daemonLog(`telemetry: settings changed, reconfiguring adapter`);
54
+ // Flush and shutdown old adapter before creating new one
55
+ const oldAdapter = this.adapter;
56
+ (async () => {
57
+ try {
58
+ await oldAdapter.flush();
59
+ await oldAdapter.shutdown();
60
+ }
61
+ catch (err) {
62
+ daemonLog(`telemetry: flush/shutdown during reconfigure failed: ${String(err)}`);
63
+ }
64
+ })();
65
+ // Create new adapter with updated settings
66
+ this.adapter = this.createAdapter(newSettings);
67
+ }
68
+ /** Get the current telemetry adapter */
69
+ getAdapter() {
70
+ return this.adapter;
71
+ }
72
+ /** Get telemetry status */
73
+ getStatus() {
74
+ return this.adapter.getStatus();
75
+ }
76
+ /**
77
+ * Test the telemetry connection by emitting a diagnostic span
78
+ * and attempting a flush. Returns success or error message.
79
+ */
80
+ async testConnection() {
81
+ const firstAttempt = await this.testAdapterConnection();
82
+ if (firstAttempt.success || firstAttempt.message === "OpenTelemetry is disabled" || firstAttempt.message === "No endpoint configured") {
83
+ return firstAttempt;
84
+ }
85
+ // Recreate the exporter once so a first-run or collector startup race does not
86
+ // require the user to toggle telemetry off and on manually.
87
+ daemonLog(`telemetry: connection test failed, recreating adapter before retry: ${firstAttempt.message}`);
88
+ await this.recreateAdapter();
89
+ return this.testAdapterConnection();
90
+ }
91
+ async testAdapterConnection() {
92
+ const status = this.adapter.getStatus();
93
+ if (!status.enabled) {
94
+ return { success: false, message: "OpenTelemetry is disabled" };
95
+ }
96
+ if (!status.endpoint) {
97
+ return { success: false, message: "No endpoint configured" };
98
+ }
99
+ try {
100
+ // Start and end a diagnostic span
101
+ const span = this.adapter.startLoop({
102
+ loopId: "diagnostic",
103
+ loopName: "connection-test",
104
+ runId: `test-${Date.now()}`,
105
+ });
106
+ span.setAttribute("loop_task.diagnostic", true);
107
+ span.ok();
108
+ // Attempt a bounded flush
109
+ await Promise.race([
110
+ this.adapter.flush(),
111
+ new Promise((_, reject) => setTimeout(() => reject(new Error("flush timeout")), 5000)),
112
+ ]);
113
+ return { success: true, message: status.endpoint };
114
+ }
115
+ catch (err) {
116
+ const msg = err instanceof Error ? err.message : String(err);
117
+ return { success: false, message: msg };
118
+ }
119
+ }
120
+ async recreateAdapter() {
121
+ const oldAdapter = this.adapter;
122
+ try {
123
+ await oldAdapter.shutdown();
124
+ }
125
+ catch (err) {
126
+ daemonLog(`telemetry: adapter recreation shutdown failed: ${String(err)}`);
127
+ }
128
+ this.adapter = this.createAdapter(this.settings);
129
+ }
130
+ /** Flush pending telemetry */
131
+ async flush() {
132
+ await this.adapter.flush();
133
+ }
134
+ /** Shutdown telemetry gracefully */
135
+ async shutdown() {
136
+ try {
137
+ await Promise.race([
138
+ this.adapter.flush(),
139
+ new Promise((_, reject) => setTimeout(() => reject(new Error("flush timeout")), 5000)),
140
+ ]);
141
+ }
142
+ catch (err) {
143
+ daemonLog(`telemetry: flush during shutdown failed: ${String(err)}`);
144
+ }
145
+ await this.adapter.shutdown();
146
+ }
147
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Redaction utilities for telemetry.
3
+ * Ensures secrets, prompts, and sensitive content are never exported.
4
+ */
5
+ /** Patterns that indicate a secret or authentication value */
6
+ const SECRET_PATTERNS = [
7
+ /api[_-]?key/i,
8
+ /auth(orization)?/i,
9
+ /token/i,
10
+ /secret/i,
11
+ /password/i,
12
+ /credential/i,
13
+ /otlp[_-]?header/i,
14
+ ];
15
+ /** Environment variables that should never be captured in telemetry */
16
+ const SENSITIVE_ENV_VARS = new Set([
17
+ "OTEL_EXPORTER_OTLP_HEADERS",
18
+ "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
19
+ "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
20
+ "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
21
+ ]);
22
+ /**
23
+ * Sanitize command arguments for telemetry.
24
+ * Removes prompt content, preserves argument count and structure.
25
+ */
26
+ export function sanitizeCommandArgs(args, captureContent) {
27
+ if (captureContent) {
28
+ return { argumentCount: args.length, sanitizedArgs: args };
29
+ }
30
+ return {
31
+ argumentCount: args.length,
32
+ sanitizedArgs: args.map(() => "<redacted>"),
33
+ };
34
+ }
35
+ /**
36
+ * Check if an environment variable name is safe to include in telemetry.
37
+ */
38
+ export function isSafeEnvVar(name) {
39
+ if (SENSITIVE_ENV_VARS.has(name))
40
+ return false;
41
+ return !SECRET_PATTERNS.some((p) => p.test(name));
42
+ }
43
+ /**
44
+ * Sanitize environment variables for telemetry output.
45
+ * Returns only the names of safe variables, never values.
46
+ */
47
+ export function sanitizeEnvVarNames(names) {
48
+ return names.filter(isSafeEnvVar);
49
+ }
50
+ /**
51
+ * Redact header values for display. Never expose actual values.
52
+ */
53
+ export function redactHeaders(headers) {
54
+ if (Object.keys(headers).length === 0)
55
+ return "not configured";
56
+ return "configured";
57
+ }
58
+ /**
59
+ * Sanitize a command line for telemetry display.
60
+ * Replaces quoted strings (likely prompts) with a placeholder.
61
+ */
62
+ export function sanitizeCommandLine(command, captureContent) {
63
+ if (captureContent)
64
+ return command;
65
+ // Replace content in quotes with <redacted>
66
+ return command.replace(/"([^"\\]|\\.)*"/g, '"<redacted>"').replace(/'([^'\\]|\\.)*'/g, "'<redacted>'");
67
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Internal telemetry types used by the daemon telemetry system.
3
+ * These are NOT the same as DaemonSettings telemetry fields — they are
4
+ * the runtime representations used by TelemetryManager and adapters.
5
+ */
6
+ /** Stable span names — never include dynamic identifiers */
7
+ export const SPAN_NAMES = {
8
+ LOOP_RUN: "loop_task.loop.run",
9
+ LOOP_RESOLVE: "loop_task.loop.resolve",
10
+ TASK_EXECUTE: "loop_task.task.execute",
11
+ COMMAND_EXECUTE: "loop_task.command.execute",
12
+ AGENT_EXECUTE: "loop_task.agent.execute",
13
+ GIT_COMMIT: "loop_task.git.commit",
14
+ GITHUB_ISSUE_UPDATE: "loop_task.github.issue.update",
15
+ GITHUB_PR_CREATE: "loop_task.github.pull_request.create",
16
+ };
17
+ /** Correlation attribute keys */
18
+ export const CORRELATION_KEYS = {
19
+ RUN_ID: "loop_task.run.id",
20
+ LOOP_ID: "loop_task.loop.id",
21
+ LOOP_NAME: "loop_task.loop.name",
22
+ TASK_ID: "loop_task.task.id",
23
+ TASK_NAME: "loop_task.task.name",
24
+ PROJECT_ID: "loop_task.project.id",
25
+ PROJECT_NAME: "loop_task.project.name",
26
+ AGENT_INTEGRATION: "loop_task.agent.integration",
27
+ };
28
+ /** Metric names — bounded cardinality */
29
+ export const METRIC_NAMES = {
30
+ RUNS: "loop_task.runs",
31
+ RUN_DURATION: "loop_task.run.duration",
32
+ TASKS: "loop_task.tasks",
33
+ TASK_DURATION: "loop_task.task.duration",
34
+ TASK_RETRIES: "loop_task.task.retries",
35
+ COMMANDS: "loop_task.commands",
36
+ COMMAND_DURATION: "loop_task.command.duration",
37
+ AGENT_EXECUTIONS: "loop_task.agent.executions",
38
+ AGENT_DURATION: "loop_task.agent.duration",
39
+ AGENT_INPUT_TOKENS: "loop_task.agent.input_tokens",
40
+ AGENT_OUTPUT_TOKENS: "loop_task.agent.output_tokens",
41
+ AGENT_CACHE_READ_TOKENS: "loop_task.agent.cache_read_tokens",
42
+ AGENT_CACHE_WRITE_TOKENS: "loop_task.agent.cache_write_tokens",
43
+ AGENT_COST: "loop_task.agent.cost",
44
+ FAILURES: "loop_task.failures",
45
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,250 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { loopsJson, tasksJson, projectsJson } from "../../shared/config/paths.js";
5
+ const DEBOUNCE_MS = 300;
6
+ const MTIME_POLL_MS = 2000;
7
+ export class FileWatcher {
8
+ constructor() {
9
+ this.watchers = new Map();
10
+ this.mtimeTimers = new Map();
11
+ this.dirWatchers = new Map();
12
+ this.loopManager = null;
13
+ this.taskManager = null;
14
+ this.projectManager = null;
15
+ this.recipeScanner = null;
16
+ this.deferredReload = null;
17
+ }
18
+ setManagers(loopManager, taskManager, projectManager) {
19
+ this.loopManager = loopManager;
20
+ this.taskManager = taskManager;
21
+ this.projectManager = projectManager;
22
+ }
23
+ setRecipeScanner(scanner, deferredReload) {
24
+ this.recipeScanner = scanner;
25
+ this.deferredReload = deferredReload;
26
+ }
27
+ start() {
28
+ this.watch(loopsJson(), (content) => this.handleLoopsChange(content));
29
+ this.watch(tasksJson(), (content) => this.handleTasksChange(content));
30
+ this.watch(projectsJson(), (content) => this.handleProjectsChange(content));
31
+ }
32
+ stop() {
33
+ for (const [, entry] of this.watchers) {
34
+ if (entry.debounceTimer)
35
+ clearTimeout(entry.debounceTimer);
36
+ }
37
+ for (const [, timer] of this.mtimeTimers) {
38
+ clearInterval(timer);
39
+ }
40
+ this.watchers.clear();
41
+ this.mtimeTimers.clear();
42
+ }
43
+ registerSelfWrite(filePath, content) {
44
+ const hash = this.hash(content);
45
+ const entry = this.watchers.get(filePath);
46
+ if (entry) {
47
+ entry.lastHash = hash;
48
+ }
49
+ }
50
+ watch(filePath, onChange) {
51
+ const dir = path.dirname(filePath);
52
+ const basename = path.basename(filePath);
53
+ this.watchers.set(filePath, {
54
+ lastHash: "",
55
+ debounceTimer: null,
56
+ lastMtime: 0,
57
+ });
58
+ try {
59
+ const initialContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : "";
60
+ this.watchers.get(filePath).lastHash = this.hash(initialContent);
61
+ this.watchers.get(filePath).lastMtime = fs.statSync(filePath).mtimeMs;
62
+ }
63
+ catch {
64
+ // file doesn't exist yet
65
+ }
66
+ try {
67
+ fs.watch(dir, (eventType, filename) => {
68
+ if (filename !== basename)
69
+ return;
70
+ this.handleFileEvent(filePath, onChange);
71
+ });
72
+ }
73
+ catch {
74
+ // fs.watch not available, mtime polling will handle it
75
+ }
76
+ const mtimeTimer = setInterval(() => {
77
+ this.checkMtime(filePath, onChange);
78
+ }, MTIME_POLL_MS);
79
+ this.mtimeTimers.set(filePath, mtimeTimer);
80
+ }
81
+ handleFileEvent(filePath, onChange) {
82
+ const entry = this.watchers.get(filePath);
83
+ if (!entry)
84
+ return;
85
+ if (entry.debounceTimer) {
86
+ clearTimeout(entry.debounceTimer);
87
+ }
88
+ entry.debounceTimer = setTimeout(() => {
89
+ this.processChange(filePath, onChange);
90
+ entry.debounceTimer = null;
91
+ }, DEBOUNCE_MS);
92
+ }
93
+ checkMtime(filePath, onChange) {
94
+ const entry = this.watchers.get(filePath);
95
+ if (!entry)
96
+ return;
97
+ try {
98
+ const stat = fs.statSync(filePath);
99
+ if (stat.mtimeMs > entry.lastMtime) {
100
+ entry.lastMtime = stat.mtimeMs;
101
+ this.handleFileEvent(filePath, onChange);
102
+ }
103
+ }
104
+ catch {
105
+ // file might not exist
106
+ }
107
+ }
108
+ processChange(filePath, onChange) {
109
+ const entry = this.watchers.get(filePath);
110
+ if (!entry)
111
+ return;
112
+ let content;
113
+ try {
114
+ content = fs.readFileSync(filePath, "utf-8");
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ const fileHash = this.hash(content);
120
+ if (fileHash === entry.lastHash) {
121
+ return;
122
+ }
123
+ entry.lastHash = fileHash;
124
+ try {
125
+ const stat = fs.statSync(filePath);
126
+ entry.lastMtime = stat.mtimeMs;
127
+ }
128
+ catch {
129
+ // ignore
130
+ }
131
+ try {
132
+ onChange(content);
133
+ }
134
+ catch (error) {
135
+ console.error(`[file-watcher] Error processing change to ${filePath}:`, error);
136
+ }
137
+ }
138
+ hash(content) {
139
+ return createHash("sha1").update(content).digest("hex").slice(0, 16);
140
+ }
141
+ handleLoopsChange(content) {
142
+ if (!this.loopManager)
143
+ return;
144
+ let newLoops;
145
+ try {
146
+ newLoops = JSON.parse(content);
147
+ }
148
+ catch {
149
+ console.error("[file-watcher] Malformed loops.json, keeping old state");
150
+ return;
151
+ }
152
+ this.loopManager.reconcile(newLoops);
153
+ }
154
+ handleTasksChange(content) {
155
+ if (!this.taskManager)
156
+ return;
157
+ let newTasks;
158
+ try {
159
+ newTasks = JSON.parse(content);
160
+ }
161
+ catch {
162
+ console.error("[file-watcher] Malformed tasks.json, keeping old state");
163
+ return;
164
+ }
165
+ this.taskManager.reload(newTasks);
166
+ }
167
+ handleProjectsChange(content) {
168
+ if (!this.projectManager)
169
+ return;
170
+ let newProjects;
171
+ try {
172
+ newProjects = JSON.parse(content);
173
+ }
174
+ catch {
175
+ console.error("[file-watcher] Malformed projects.json, keeping old state");
176
+ return;
177
+ }
178
+ this.projectManager.reload(newProjects);
179
+ }
180
+ watchRecipeDirectory(projectId, recipesDir) {
181
+ if (this.dirWatchers.has(recipesDir))
182
+ return;
183
+ this.dirWatchers.set(recipesDir, { debounceTimer: null });
184
+ try {
185
+ fs.mkdirSync(recipesDir, { recursive: true });
186
+ }
187
+ catch {
188
+ // directory may not be creatable (remote fs, permissions)
189
+ }
190
+ try {
191
+ fs.watch(recipesDir, (eventType, filename) => {
192
+ if (!filename || (!filename.endsWith(".yaml") && !filename.endsWith(".yml")))
193
+ return;
194
+ this.handleRecipeFileEvent(projectId, recipesDir, filename);
195
+ });
196
+ }
197
+ catch {
198
+ // fs.watch not available on this path
199
+ }
200
+ }
201
+ stopWatchingRecipeDirectory(recipesDir) {
202
+ const entry = this.dirWatchers.get(recipesDir);
203
+ if (entry?.debounceTimer) {
204
+ clearTimeout(entry.debounceTimer);
205
+ }
206
+ this.dirWatchers.delete(recipesDir);
207
+ }
208
+ handleRecipeFileEvent(projectId, recipesDir, filename) {
209
+ if (!this.recipeScanner)
210
+ return;
211
+ const entry = this.dirWatchers.get(recipesDir);
212
+ if (!entry)
213
+ return;
214
+ if (entry.debounceTimer) {
215
+ clearTimeout(entry.debounceTimer);
216
+ }
217
+ entry.debounceTimer = setTimeout(() => {
218
+ entry.debounceTimer = null;
219
+ this.processRecipeFileChange(projectId, recipesDir, filename);
220
+ }, DEBOUNCE_MS);
221
+ }
222
+ processRecipeFileChange(projectId, recipesDir, filename) {
223
+ if (!this.recipeScanner || !this.loopManager)
224
+ return;
225
+ const filePath = path.join(recipesDir, filename);
226
+ const existingRecipe = this.recipeScanner.findRecipeByPath(filePath);
227
+ if (!fs.existsSync(filePath)) {
228
+ if (existingRecipe) {
229
+ this.recipeScanner.unloadRecipe(existingRecipe.id);
230
+ this.deferredReload?.cancelReload(existingRecipe.id);
231
+ }
232
+ return;
233
+ }
234
+ if (existingRecipe) {
235
+ const isRunning = this.loopManager.isRunning(existingRecipe.id);
236
+ if (isRunning && this.deferredReload) {
237
+ const entry = this.loopManager["recipes"].get(existingRecipe.id);
238
+ if (entry) {
239
+ this.deferredReload.requestReload(existingRecipe.id, filePath, entry.controller);
240
+ }
241
+ }
242
+ else {
243
+ this.recipeScanner.reloadRecipe(existingRecipe.id);
244
+ }
245
+ }
246
+ else {
247
+ this.recipeScanner.loadRecipe(projectId, filePath, filename);
248
+ }
249
+ }
250
+ }
@@ -0,0 +1,24 @@
1
+ import ms from "ms";
2
+ import { t } from "./shared/i18n/index.js";
3
+ export function parseDuration(input) {
4
+ const trimmed = input.trim();
5
+ if (!trimmed) {
6
+ throw new Error(t("errors.durationEmpty"));
7
+ }
8
+ if (trimmed === "manual" || trimmed === "0") {
9
+ return 0;
10
+ }
11
+ const result = ms(trimmed);
12
+ if (typeof result !== "number" || isNaN(result)) {
13
+ throw new Error(t("errors.durationInvalid", { input }));
14
+ }
15
+ if (result <= 0) {
16
+ throw new Error(t("errors.durationNotPositive", { input }));
17
+ }
18
+ return result;
19
+ }
20
+ export function formatDuration(value) {
21
+ if (value === 0)
22
+ return t("format.durationManual");
23
+ return ms(value, { long: true });
24
+ }
@@ -0,0 +1,109 @@
1
+ export const defaultFilters = {
2
+ status: "all",
3
+ intervalBucket: "all",
4
+ recentActivity: "all",
5
+ query: "",
6
+ };
7
+ const statusOrder = {
8
+ running: 0,
9
+ waiting: 1,
10
+ paused: 2,
11
+ idle: 3,
12
+ stopped: 4,
13
+ };
14
+ function intervalBucketOf(interval) {
15
+ if (interval === 0)
16
+ return "manual";
17
+ if (interval <= 60_000)
18
+ return "short";
19
+ if (interval <= 3_600_000)
20
+ return "medium";
21
+ return "long";
22
+ }
23
+ function activityBucketOf(loop) {
24
+ const baseline = loop.lastRunAt ?? loop.createdAt;
25
+ const ageMs = Date.now() - new Date(baseline).getTime();
26
+ return ageMs <= 15 * 60_000 ? "active" : "stale";
27
+ }
28
+ function matches(loop, filters) {
29
+ if (filters.status !== "all" && loop.status !== filters.status) {
30
+ return false;
31
+ }
32
+ if (filters.intervalBucket !== "all" &&
33
+ intervalBucketOf(loop.interval) !== filters.intervalBucket) {
34
+ return false;
35
+ }
36
+ if (filters.recentActivity !== "all" &&
37
+ activityBucketOf(loop) !== filters.recentActivity) {
38
+ return false;
39
+ }
40
+ const query = filters.query.trim().toLowerCase();
41
+ if (!query) {
42
+ return true;
43
+ }
44
+ const haystack = [
45
+ loop.id,
46
+ loop.command,
47
+ ...loop.commandArgs,
48
+ loop.description ?? "",
49
+ loop.status,
50
+ loop.intervalHuman,
51
+ ]
52
+ .join(" ")
53
+ .toLowerCase();
54
+ return haystack.includes(query);
55
+ }
56
+ function describeLoop(loop) {
57
+ return loop.description?.trim() || [loop.command, ...loop.commandArgs].join(" ").trim();
58
+ }
59
+ function projectSortName(loop, projects) {
60
+ if (!loop.projectId)
61
+ return "\uFFFF";
62
+ const proj = projects.find((p) => p.id === loop.projectId);
63
+ return proj?.name ?? "\uFFFF";
64
+ }
65
+ function compare(left, right, sort, projects) {
66
+ if (sort === "description") {
67
+ const byProject = projectSortName(left, projects).localeCompare(projectSortName(right, projects));
68
+ if (byProject !== 0)
69
+ return byProject;
70
+ return describeLoop(left).localeCompare(describeLoop(right));
71
+ }
72
+ if (sort === "created") {
73
+ return right.createdAt.localeCompare(left.createdAt);
74
+ }
75
+ if (sort === "recent") {
76
+ return (right.lastRunAt ?? right.createdAt).localeCompare(left.lastRunAt ?? left.createdAt);
77
+ }
78
+ const byStatus = statusOrder[left.status] - statusOrder[right.status];
79
+ if (byStatus !== 0) {
80
+ return byStatus;
81
+ }
82
+ const leftNext = left.nextRunAt ?? left.lastRunAt ?? left.createdAt;
83
+ const rightNext = right.nextRunAt ?? right.lastRunAt ?? right.createdAt;
84
+ return rightNext.localeCompare(leftNext);
85
+ }
86
+ export function applyLoopFilters(loops, filters, sort, projects = []) {
87
+ return loops
88
+ .filter((loop) => matches(loop, filters))
89
+ .sort((left, right) => compare(left, right, sort, projects));
90
+ }
91
+ const SORT_CYCLE = {
92
+ description: "status",
93
+ status: "recent",
94
+ recent: "created",
95
+ created: "description",
96
+ };
97
+ export function cycleSortMode(mode) {
98
+ return SORT_CYCLE[mode];
99
+ }
100
+ const STATUS_CYCLE = {
101
+ all: "running",
102
+ running: "waiting",
103
+ waiting: "paused",
104
+ paused: "idle",
105
+ idle: "all",
106
+ };
107
+ export function cycleStatusFilter(status) {
108
+ return STATUS_CYCLE[status];
109
+ }
@@ -0,0 +1 @@
1
+ export { defaultFilters, applyLoopFilters, cycleSortMode, cycleStatusFilter, } from "./filters.js";