@kbediako/codex-orchestrator 0.1.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.
- package/LICENSE +7 -0
- package/README.md +238 -0
- package/dist/bin/codex-orchestrator.js +507 -0
- package/dist/orchestrator/src/agents/builder.js +16 -0
- package/dist/orchestrator/src/agents/index.js +4 -0
- package/dist/orchestrator/src/agents/planner.js +17 -0
- package/dist/orchestrator/src/agents/reviewer.js +13 -0
- package/dist/orchestrator/src/agents/tester.js +13 -0
- package/dist/orchestrator/src/cli/adapters/CommandBuilder.js +20 -0
- package/dist/orchestrator/src/cli/adapters/CommandPlanner.js +164 -0
- package/dist/orchestrator/src/cli/adapters/CommandReviewer.js +32 -0
- package/dist/orchestrator/src/cli/adapters/CommandTester.js +33 -0
- package/dist/orchestrator/src/cli/adapters/index.js +4 -0
- package/dist/orchestrator/src/cli/config/userConfig.js +28 -0
- package/dist/orchestrator/src/cli/doctor.js +48 -0
- package/dist/orchestrator/src/cli/events/runEvents.js +84 -0
- package/dist/orchestrator/src/cli/exec/command.js +56 -0
- package/dist/orchestrator/src/cli/exec/context.js +108 -0
- package/dist/orchestrator/src/cli/exec/experience.js +77 -0
- package/dist/orchestrator/src/cli/exec/finalization.js +140 -0
- package/dist/orchestrator/src/cli/exec/learning.js +62 -0
- package/dist/orchestrator/src/cli/exec/stageRunner.js +71 -0
- package/dist/orchestrator/src/cli/exec/summary.js +109 -0
- package/dist/orchestrator/src/cli/exec/telemetry.js +18 -0
- package/dist/orchestrator/src/cli/exec/tfgrpo.js +200 -0
- package/dist/orchestrator/src/cli/exec/tfgrpoArtifacts.js +19 -0
- package/dist/orchestrator/src/cli/exec/types.js +1 -0
- package/dist/orchestrator/src/cli/init.js +64 -0
- package/dist/orchestrator/src/cli/mcp.js +124 -0
- package/dist/orchestrator/src/cli/metrics/metricsAggregator.js +404 -0
- package/dist/orchestrator/src/cli/metrics/metricsRecorder.js +138 -0
- package/dist/orchestrator/src/cli/orchestrator.js +554 -0
- package/dist/orchestrator/src/cli/pipelines/defaultDiagnostics.js +32 -0
- package/dist/orchestrator/src/cli/pipelines/designReference.js +72 -0
- package/dist/orchestrator/src/cli/pipelines/hiFiDesignToolkit.js +71 -0
- package/dist/orchestrator/src/cli/pipelines/index.js +34 -0
- package/dist/orchestrator/src/cli/run/environment.js +24 -0
- package/dist/orchestrator/src/cli/run/manifest.js +367 -0
- package/dist/orchestrator/src/cli/run/manifestPersister.js +88 -0
- package/dist/orchestrator/src/cli/run/runPaths.js +30 -0
- package/dist/orchestrator/src/cli/selfCheck.js +12 -0
- package/dist/orchestrator/src/cli/services/commandRunner.js +420 -0
- package/dist/orchestrator/src/cli/services/controlPlaneService.js +107 -0
- package/dist/orchestrator/src/cli/services/execRuntime.js +69 -0
- package/dist/orchestrator/src/cli/services/pipelineResolver.js +47 -0
- package/dist/orchestrator/src/cli/services/runPreparation.js +82 -0
- package/dist/orchestrator/src/cli/services/runSummaryWriter.js +35 -0
- package/dist/orchestrator/src/cli/services/schedulerService.js +42 -0
- package/dist/orchestrator/src/cli/tasks/taskMetadata.js +19 -0
- package/dist/orchestrator/src/cli/telemetry/schema.js +8 -0
- package/dist/orchestrator/src/cli/types.js +1 -0
- package/dist/orchestrator/src/cli/ui/HudApp.js +112 -0
- package/dist/orchestrator/src/cli/ui/controller.js +26 -0
- package/dist/orchestrator/src/cli/ui/store.js +240 -0
- package/dist/orchestrator/src/cli/utils/enforcementMode.js +12 -0
- package/dist/orchestrator/src/cli/utils/fs.js +8 -0
- package/dist/orchestrator/src/cli/utils/interactive.js +25 -0
- package/dist/orchestrator/src/cli/utils/jsonlWriter.js +10 -0
- package/dist/orchestrator/src/cli/utils/optionalDeps.js +30 -0
- package/dist/orchestrator/src/cli/utils/packageInfo.js +25 -0
- package/dist/orchestrator/src/cli/utils/planFormatter.js +49 -0
- package/dist/orchestrator/src/cli/utils/runId.js +7 -0
- package/dist/orchestrator/src/cli/utils/specGuardRunner.js +26 -0
- package/dist/orchestrator/src/cli/utils/strings.js +8 -0
- package/dist/orchestrator/src/cli/utils/time.js +6 -0
- package/dist/orchestrator/src/control-plane/drift-reporter.js +109 -0
- package/dist/orchestrator/src/control-plane/index.js +3 -0
- package/dist/orchestrator/src/control-plane/request-builder.js +217 -0
- package/dist/orchestrator/src/control-plane/types.js +1 -0
- package/dist/orchestrator/src/control-plane/validator.js +50 -0
- package/dist/orchestrator/src/credentials/CredentialBroker.js +1 -0
- package/dist/orchestrator/src/events/EventBus.js +25 -0
- package/dist/orchestrator/src/learning/crystalizer.js +108 -0
- package/dist/orchestrator/src/learning/harvester.js +146 -0
- package/dist/orchestrator/src/learning/manifest.js +56 -0
- package/dist/orchestrator/src/learning/runner.js +177 -0
- package/dist/orchestrator/src/learning/validator.js +164 -0
- package/dist/orchestrator/src/logger.js +20 -0
- package/dist/orchestrator/src/manager.js +388 -0
- package/dist/orchestrator/src/persistence/ArtifactStager.js +95 -0
- package/dist/orchestrator/src/persistence/ExperienceStore.js +210 -0
- package/dist/orchestrator/src/persistence/PersistenceCoordinator.js +65 -0
- package/dist/orchestrator/src/persistence/RunManifestWriter.js +23 -0
- package/dist/orchestrator/src/persistence/TaskStateStore.js +172 -0
- package/dist/orchestrator/src/persistence/identifierGuards.js +1 -0
- package/dist/orchestrator/src/persistence/lockFile.js +26 -0
- package/dist/orchestrator/src/persistence/sanitizeIdentifier.js +26 -0
- package/dist/orchestrator/src/persistence/sanitizeRunId.js +8 -0
- package/dist/orchestrator/src/persistence/sanitizeTaskId.js +8 -0
- package/dist/orchestrator/src/persistence/writeAtomicFile.js +4 -0
- package/dist/orchestrator/src/privacy/guard.js +111 -0
- package/dist/orchestrator/src/scheduler/index.js +1 -0
- package/dist/orchestrator/src/scheduler/plan.js +171 -0
- package/dist/orchestrator/src/scheduler/types.js +1 -0
- package/dist/orchestrator/src/sync/CloudRunsClient.js +1 -0
- package/dist/orchestrator/src/sync/CloudRunsHttpClient.js +82 -0
- package/dist/orchestrator/src/sync/CloudSyncWorker.js +206 -0
- package/dist/orchestrator/src/sync/createCloudSyncWorker.js +15 -0
- package/dist/orchestrator/src/types.js +1 -0
- package/dist/orchestrator/src/utils/atomicWrite.js +15 -0
- package/dist/orchestrator/src/utils/errorMessage.js +14 -0
- package/dist/orchestrator/src/utils/executionMode.js +69 -0
- package/dist/packages/control-plane-schemas/src/index.js +1 -0
- package/dist/packages/control-plane-schemas/src/run-request.js +548 -0
- package/dist/packages/orchestrator/src/exec/handle-service.js +203 -0
- package/dist/packages/orchestrator/src/exec/session-manager.js +147 -0
- package/dist/packages/orchestrator/src/exec/unified-exec.js +432 -0
- package/dist/packages/orchestrator/src/index.js +3 -0
- package/dist/packages/orchestrator/src/instructions/loader.js +101 -0
- package/dist/packages/orchestrator/src/instructions/promptPacks.js +151 -0
- package/dist/packages/orchestrator/src/notifications/index.js +74 -0
- package/dist/packages/orchestrator/src/telemetry/otel-exporter.js +142 -0
- package/dist/packages/orchestrator/src/tool-orchestrator.js +161 -0
- package/dist/packages/sdk-node/src/orchestrator.js +195 -0
- package/dist/packages/shared/config/designConfig.js +495 -0
- package/dist/packages/shared/config/env.js +37 -0
- package/dist/packages/shared/config/index.js +2 -0
- package/dist/packages/shared/design-artifacts/writer.js +221 -0
- package/dist/packages/shared/events/serializer.js +84 -0
- package/dist/packages/shared/events/types.js +1 -0
- package/dist/packages/shared/manifest/artifactUtils.js +36 -0
- package/dist/packages/shared/manifest/designArtifacts.js +665 -0
- package/dist/packages/shared/manifest/fileIO.js +29 -0
- package/dist/packages/shared/manifest/toolRuns.js +78 -0
- package/dist/packages/shared/manifest/toolkitArtifacts.js +223 -0
- package/dist/packages/shared/manifest/types.js +5 -0
- package/dist/packages/shared/manifest/validator.js +73 -0
- package/dist/packages/shared/manifest/writer.js +2 -0
- package/dist/packages/shared/streams/stdio.js +112 -0
- package/dist/scripts/design/pipeline/advanced-assets.js +466 -0
- package/dist/scripts/design/pipeline/componentize.js +74 -0
- package/dist/scripts/design/pipeline/context.js +34 -0
- package/dist/scripts/design/pipeline/extract.js +249 -0
- package/dist/scripts/design/pipeline/optionalDeps.js +107 -0
- package/dist/scripts/design/pipeline/prepare.js +46 -0
- package/dist/scripts/design/pipeline/reference.js +94 -0
- package/dist/scripts/design/pipeline/state.js +206 -0
- package/dist/scripts/design/pipeline/toolkit/common.js +94 -0
- package/dist/scripts/design/pipeline/toolkit/extract.js +258 -0
- package/dist/scripts/design/pipeline/toolkit/publish.js +202 -0
- package/dist/scripts/design/pipeline/toolkit/publishActions.js +12 -0
- package/dist/scripts/design/pipeline/toolkit/reference.js +846 -0
- package/dist/scripts/design/pipeline/toolkit/snapshot.js +882 -0
- package/dist/scripts/design/pipeline/toolkit/tokens.js +456 -0
- package/dist/scripts/design/pipeline/visual-regression.js +137 -0
- package/dist/scripts/design/pipeline/write-artifacts.js +61 -0
- package/package.json +97 -0
- package/schemas/manifest.json +1064 -0
- package/templates/README.md +12 -0
- package/templates/codex/mcp-client.json +8 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { logger } from '../logger.js';
|
|
2
|
+
import { TaskStateStoreLockError } from './TaskStateStore.js';
|
|
3
|
+
/**
|
|
4
|
+
* Subscribes to `run:completed` events and persists run metadata locally
|
|
5
|
+
* leveraging the TaskStateStore and RunManifestWriter. Errors are surfaced via
|
|
6
|
+
* the optional `onError` callback but do not throw to avoid crashing the manager.
|
|
7
|
+
*/
|
|
8
|
+
export class PersistenceCoordinator {
|
|
9
|
+
eventBus;
|
|
10
|
+
stateStore;
|
|
11
|
+
manifestWriter;
|
|
12
|
+
options;
|
|
13
|
+
unsubscribe;
|
|
14
|
+
constructor(eventBus, stateStore, manifestWriter, options = {}) {
|
|
15
|
+
this.eventBus = eventBus;
|
|
16
|
+
this.stateStore = stateStore;
|
|
17
|
+
this.manifestWriter = manifestWriter;
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
start() {
|
|
21
|
+
if (this.unsubscribe) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
this.unsubscribe = this.eventBus.on('run:completed', (event) => {
|
|
25
|
+
void this.handleRunCompleted(event.payload);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
stop() {
|
|
29
|
+
if (this.unsubscribe) {
|
|
30
|
+
this.unsubscribe();
|
|
31
|
+
this.unsubscribe = undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async handleRunCompleted(summary) {
|
|
35
|
+
let stateStoreError = null;
|
|
36
|
+
try {
|
|
37
|
+
await this.stateStore.recordRun(summary);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
stateStoreError = error;
|
|
41
|
+
if (error instanceof TaskStateStoreLockError) {
|
|
42
|
+
logger.warn(`Task state snapshot skipped for task ${summary.taskId} (run ${summary.runId}): ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
logger.error(`Task state snapshot failed for task ${summary.taskId} (run ${summary.runId})`, error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await this.manifestWriter.write(summary);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
this.options.onError?.(error, summary);
|
|
53
|
+
if (!this.options.onError) {
|
|
54
|
+
logger.error(`PersistenceCoordinator manifest write error for task ${summary.taskId} (run ${summary.runId})`, error);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (stateStoreError) {
|
|
59
|
+
this.options.onError?.(stateStoreError, summary);
|
|
60
|
+
if (!this.options.onError) {
|
|
61
|
+
logger.warn(`Task state snapshot not recorded for task ${summary.taskId} (run ${summary.runId})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { sanitizeTaskId } from './sanitizeTaskId.js';
|
|
4
|
+
import { sanitizeRunId } from './sanitizeRunId.js';
|
|
5
|
+
/**
|
|
6
|
+
* Stores the run-level manifest under `.runs/<taskId>/<runId>/manifest.json`
|
|
7
|
+
* so downstream tooling (cloud-sync worker, reviewers) can ingest the payload.
|
|
8
|
+
*/
|
|
9
|
+
export class RunManifestWriter {
|
|
10
|
+
runsDir;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.runsDir = options.runsDir ?? join(process.cwd(), '.runs');
|
|
13
|
+
}
|
|
14
|
+
async write(summary) {
|
|
15
|
+
const safeTaskId = sanitizeTaskId(summary.taskId);
|
|
16
|
+
const safeRunId = sanitizeRunId(summary.runId);
|
|
17
|
+
const runDir = join(this.runsDir, safeTaskId, safeRunId);
|
|
18
|
+
await mkdir(runDir, { recursive: true });
|
|
19
|
+
const manifestPath = join(runDir, 'manifest.json');
|
|
20
|
+
await writeFile(manifestPath, JSON.stringify(summary, null, 2), 'utf-8');
|
|
21
|
+
return manifestPath;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { acquireLockWithRetry } from './lockFile.js';
|
|
4
|
+
import { sanitizeTaskId } from './sanitizeTaskId.js';
|
|
5
|
+
import { writeAtomicFile } from './writeAtomicFile.js';
|
|
6
|
+
export class TaskStateStoreLockError extends Error {
|
|
7
|
+
taskId;
|
|
8
|
+
constructor(message, taskId) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.taskId = taskId;
|
|
11
|
+
this.name = 'TaskStateStoreLockError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Persists orchestrator run history per task under `/out/<taskId>/runs.json`.
|
|
16
|
+
* Uses advisory lock files (`.runs/<taskId>.lock`) to guard concurrent writers
|
|
17
|
+
* and performs atomic writes via temporary files + rename.
|
|
18
|
+
*/
|
|
19
|
+
export class TaskStateStore {
|
|
20
|
+
outDir;
|
|
21
|
+
runsDir;
|
|
22
|
+
lockRetry;
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.outDir = options.outDir ?? join(process.cwd(), 'out');
|
|
25
|
+
this.runsDir = options.runsDir ?? join(process.cwd(), '.runs');
|
|
26
|
+
const defaults = {
|
|
27
|
+
maxAttempts: 5,
|
|
28
|
+
initialDelayMs: 100,
|
|
29
|
+
backoffFactor: 2,
|
|
30
|
+
maxDelayMs: 1000
|
|
31
|
+
};
|
|
32
|
+
const overrides = options.lockRetry ?? {};
|
|
33
|
+
const sanitizedOverrides = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
|
|
34
|
+
this.lockRetry = { ...defaults, ...sanitizedOverrides };
|
|
35
|
+
}
|
|
36
|
+
async recordRun(summary) {
|
|
37
|
+
const safeTaskId = sanitizeTaskId(summary.taskId);
|
|
38
|
+
const lockPath = this.buildLockPath(safeTaskId);
|
|
39
|
+
await this.acquireLock(safeTaskId, lockPath);
|
|
40
|
+
try {
|
|
41
|
+
await this.ensureDirectory(this.outDir);
|
|
42
|
+
const taskOutDir = join(this.outDir, safeTaskId);
|
|
43
|
+
await this.ensureDirectory(taskOutDir);
|
|
44
|
+
const snapshotPath = join(taskOutDir, 'runs.json');
|
|
45
|
+
const snapshot = await this.loadSnapshot(snapshotPath, safeTaskId);
|
|
46
|
+
const updated = this.mergeSnapshot(snapshot, { ...summary, taskId: safeTaskId });
|
|
47
|
+
await writeAtomicFile(snapshotPath, JSON.stringify(updated, null, 2));
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await this.releaseLock(lockPath);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
buildLockPath(taskId) {
|
|
54
|
+
const safeTaskId = sanitizeTaskId(taskId);
|
|
55
|
+
return join(this.runsDir, `${safeTaskId}.lock`);
|
|
56
|
+
}
|
|
57
|
+
async acquireLock(taskId, lockPath) {
|
|
58
|
+
await acquireLockWithRetry({
|
|
59
|
+
taskId,
|
|
60
|
+
lockPath,
|
|
61
|
+
retry: this.lockRetry,
|
|
62
|
+
ensureDirectory: () => this.ensureDirectory(dirname(lockPath)),
|
|
63
|
+
createError: (id, attempts) => new TaskStateStoreLockError(`Failed to acquire task state lock for ${id} after ${attempts} attempts`, id)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async releaseLock(lockPath) {
|
|
67
|
+
await rm(lockPath, { force: true });
|
|
68
|
+
}
|
|
69
|
+
async ensureDirectory(path) {
|
|
70
|
+
await mkdir(path, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
async loadSnapshot(path, taskId) {
|
|
73
|
+
try {
|
|
74
|
+
const data = await readFile(path, 'utf-8');
|
|
75
|
+
const parsed = JSON.parse(data);
|
|
76
|
+
if (!parsed || parsed.taskId !== taskId) {
|
|
77
|
+
throw new Error(`Invalid snapshot contents for task ${taskId}`);
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') {
|
|
83
|
+
const legacyPath = join(dirname(path), 'state.json');
|
|
84
|
+
const legacy = await this.tryLoadLegacySnapshot(legacyPath, taskId);
|
|
85
|
+
return legacy ?? { taskId, lastRunAt: '', runs: [] };
|
|
86
|
+
}
|
|
87
|
+
if (error instanceof SyntaxError) {
|
|
88
|
+
throw new Error(`Corrupted snapshot at ${path}: ${error.message}`);
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async tryLoadLegacySnapshot(path, taskId) {
|
|
94
|
+
try {
|
|
95
|
+
const data = await readFile(path, 'utf-8');
|
|
96
|
+
const parsed = JSON.parse(data);
|
|
97
|
+
if (parsed &&
|
|
98
|
+
typeof parsed.taskId === 'string' &&
|
|
99
|
+
parsed.taskId === taskId &&
|
|
100
|
+
Array.isArray(parsed.runs)) {
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code === 'ENOENT') {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
mergeSnapshot(snapshot, summary) {
|
|
113
|
+
const runs = snapshot.runs;
|
|
114
|
+
const existingIndex = runs.findIndex((run) => run.runId === summary.runId);
|
|
115
|
+
if (existingIndex !== -1) {
|
|
116
|
+
const updatedRuns = runs.slice();
|
|
117
|
+
updatedRuns[existingIndex] = summary;
|
|
118
|
+
updatedRuns.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
119
|
+
const lastRunAt = updatedRuns.length > 0 ? updatedRuns[updatedRuns.length - 1].timestamp : snapshot.lastRunAt;
|
|
120
|
+
return {
|
|
121
|
+
taskId: snapshot.taskId,
|
|
122
|
+
lastRunAt,
|
|
123
|
+
runs: updatedRuns
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (runs.length === 0) {
|
|
127
|
+
runs.push(summary);
|
|
128
|
+
return {
|
|
129
|
+
taskId: snapshot.taskId,
|
|
130
|
+
lastRunAt: summary.timestamp,
|
|
131
|
+
runs
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const lastTimestamp = runs[runs.length - 1].timestamp;
|
|
135
|
+
if (lastTimestamp.localeCompare(summary.timestamp) <= 0) {
|
|
136
|
+
runs.push(summary);
|
|
137
|
+
return {
|
|
138
|
+
taskId: snapshot.taskId,
|
|
139
|
+
lastRunAt: summary.timestamp,
|
|
140
|
+
runs
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const insertIndex = this.findInsertIndex(runs, summary.timestamp);
|
|
144
|
+
runs.splice(insertIndex, 0, summary);
|
|
145
|
+
const lastRunAt = runs[runs.length - 1].timestamp;
|
|
146
|
+
return {
|
|
147
|
+
taskId: snapshot.taskId,
|
|
148
|
+
lastRunAt,
|
|
149
|
+
runs
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
findInsertIndex(runs, timestamp) {
|
|
153
|
+
let low = 0;
|
|
154
|
+
let high = runs.length - 1;
|
|
155
|
+
let result = runs.length;
|
|
156
|
+
while (low <= high) {
|
|
157
|
+
const mid = (low + high) >> 1;
|
|
158
|
+
const candidate = runs[mid];
|
|
159
|
+
if (!candidate) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
if (candidate.timestamp.localeCompare(timestamp) > 0) {
|
|
163
|
+
result = mid;
|
|
164
|
+
high = mid - 1;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
low = mid + 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WINDOWS_FORBIDDEN_CHARACTERS = new Set(['<', '>', ':', '"', '|', '?', '*']);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
export async function acquireLockWithRetry(params) {
|
|
4
|
+
await params.ensureDirectory();
|
|
5
|
+
const { maxAttempts, initialDelayMs, backoffFactor, maxDelayMs } = params.retry;
|
|
6
|
+
let attempt = 0;
|
|
7
|
+
let delayMs = initialDelayMs;
|
|
8
|
+
while (attempt < maxAttempts) {
|
|
9
|
+
attempt += 1;
|
|
10
|
+
try {
|
|
11
|
+
const handle = await open(params.lockPath, 'wx');
|
|
12
|
+
await handle.close();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code !== 'EEXIST') {
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
if (attempt >= maxAttempts) {
|
|
20
|
+
throw params.createError(params.taskId, attempt);
|
|
21
|
+
}
|
|
22
|
+
await delay(Math.min(delayMs, maxDelayMs));
|
|
23
|
+
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { WINDOWS_FORBIDDEN_CHARACTERS } from './identifierGuards.js';
|
|
2
|
+
export function sanitizeIdentifier(kind, value) {
|
|
3
|
+
const label = kind === 'task' ? 'task' : 'run';
|
|
4
|
+
if (!value) {
|
|
5
|
+
throw new Error(`Invalid ${label} ID: value must be a non-empty string.`);
|
|
6
|
+
}
|
|
7
|
+
for (const char of value) {
|
|
8
|
+
const codePoint = char.codePointAt(0);
|
|
9
|
+
if (codePoint !== undefined && (codePoint <= 31 || codePoint === 127)) {
|
|
10
|
+
throw new Error(`Invalid ${label} ID "${value}": control characters are not allowed.`);
|
|
11
|
+
}
|
|
12
|
+
if (WINDOWS_FORBIDDEN_CHARACTERS.has(char)) {
|
|
13
|
+
throw new Error(`Invalid ${label} ID "${value}": character "${char}" is not allowed.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (value.startsWith('.')) {
|
|
17
|
+
throw new Error(`Invalid ${label} ID "${value}": leading dots are not allowed.`);
|
|
18
|
+
}
|
|
19
|
+
if (value === '..') {
|
|
20
|
+
throw new Error(`Invalid ${label} ID "${value}": traversal sequences are not allowed.`);
|
|
21
|
+
}
|
|
22
|
+
if (value.includes('/') || value.includes('\\')) {
|
|
23
|
+
throw new Error(`Invalid ${label} ID "${value}": slashes are not allowed.`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { sanitizeIdentifier } from './sanitizeIdentifier.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validates run identifiers before they are used in filesystem paths.
|
|
4
|
+
* Rejects traversal sequences and characters that are unsafe on Windows.
|
|
5
|
+
*/
|
|
6
|
+
export function sanitizeRunId(runId) {
|
|
7
|
+
return sanitizeIdentifier('run', runId);
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { sanitizeIdentifier } from './sanitizeIdentifier.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validates task identifiers before they are used in filesystem paths.
|
|
4
|
+
* Rejects traversal sequences and characters that would break directory layout.
|
|
5
|
+
*/
|
|
6
|
+
export function sanitizeTaskId(taskId) {
|
|
7
|
+
return sanitizeIdentifier('task', taskId);
|
|
8
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const REDACTION_TOKEN = '[REDACTED]';
|
|
2
|
+
export class PrivacyGuard {
|
|
3
|
+
now;
|
|
4
|
+
mode;
|
|
5
|
+
decisions = [];
|
|
6
|
+
totals = {
|
|
7
|
+
total: 0,
|
|
8
|
+
redacted: 0,
|
|
9
|
+
blocked: 0,
|
|
10
|
+
allowed: 0
|
|
11
|
+
};
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.mode = options.mode ?? 'shadow';
|
|
14
|
+
this.now = options.now ?? (() => new Date());
|
|
15
|
+
}
|
|
16
|
+
getMode() {
|
|
17
|
+
return this.mode;
|
|
18
|
+
}
|
|
19
|
+
async process(frame, context) {
|
|
20
|
+
this.totals.total += 1;
|
|
21
|
+
const decision = this.evaluateFrame(frame);
|
|
22
|
+
const finalDecision = this.mode === 'shadow' && decision.action !== 'allow'
|
|
23
|
+
? { action: 'allow', rule: decision.rule, reason: 'shadow-mode' }
|
|
24
|
+
: decision;
|
|
25
|
+
const timestamp = this.now().toISOString();
|
|
26
|
+
this.decisions.push({
|
|
27
|
+
handleId: context.handleId,
|
|
28
|
+
sequence: frame.sequence,
|
|
29
|
+
action: finalDecision.action,
|
|
30
|
+
rule: finalDecision.rule,
|
|
31
|
+
reason: finalDecision.reason,
|
|
32
|
+
timestamp
|
|
33
|
+
});
|
|
34
|
+
if (finalDecision.action === 'allow') {
|
|
35
|
+
this.totals.allowed += 1;
|
|
36
|
+
return { frame, decision: finalDecision };
|
|
37
|
+
}
|
|
38
|
+
if (finalDecision.action === 'redact') {
|
|
39
|
+
this.totals.redacted += 1;
|
|
40
|
+
const redactedFrame = this.redactFrame(frame);
|
|
41
|
+
return { frame: redactedFrame, decision: finalDecision };
|
|
42
|
+
}
|
|
43
|
+
this.totals.blocked += 1;
|
|
44
|
+
return { frame: null, decision: finalDecision };
|
|
45
|
+
}
|
|
46
|
+
getMetrics() {
|
|
47
|
+
return {
|
|
48
|
+
mode: this.mode,
|
|
49
|
+
totalFrames: this.totals.total,
|
|
50
|
+
redactedFrames: this.totals.redacted,
|
|
51
|
+
blockedFrames: this.totals.blocked,
|
|
52
|
+
allowedFrames: this.totals.allowed,
|
|
53
|
+
decisions: [...this.decisions]
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
reset() {
|
|
57
|
+
this.decisions.length = 0;
|
|
58
|
+
this.totals = {
|
|
59
|
+
total: 0,
|
|
60
|
+
redacted: 0,
|
|
61
|
+
blocked: 0,
|
|
62
|
+
allowed: 0
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
evaluateFrame(frame) {
|
|
66
|
+
const event = frame.event;
|
|
67
|
+
if (event.type !== 'exec:chunk') {
|
|
68
|
+
return { action: 'allow' };
|
|
69
|
+
}
|
|
70
|
+
const { data } = event.payload;
|
|
71
|
+
if (!data) {
|
|
72
|
+
return { action: 'allow' };
|
|
73
|
+
}
|
|
74
|
+
if (PRIVATE_KEY_PATTERN.test(data)) {
|
|
75
|
+
return {
|
|
76
|
+
action: 'block',
|
|
77
|
+
rule: 'private-key',
|
|
78
|
+
reason: 'detected private key material'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (SENSITIVE_TOKEN_PATTERN.test(data) || AWS_KEY_PATTERN.test(data)) {
|
|
82
|
+
return {
|
|
83
|
+
action: 'redact',
|
|
84
|
+
rule: 'sensitive-token',
|
|
85
|
+
reason: 'detected high-risk token'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { action: 'allow' };
|
|
89
|
+
}
|
|
90
|
+
redactFrame(frame) {
|
|
91
|
+
const event = frame.event;
|
|
92
|
+
if (event.type !== 'exec:chunk') {
|
|
93
|
+
return frame;
|
|
94
|
+
}
|
|
95
|
+
const payload = {
|
|
96
|
+
...event.payload,
|
|
97
|
+
data: REDACTION_TOKEN,
|
|
98
|
+
bytes: Buffer.byteLength(REDACTION_TOKEN, 'utf8')
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
...frame,
|
|
102
|
+
event: {
|
|
103
|
+
...event,
|
|
104
|
+
payload
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const PRIVATE_KEY_PATTERN = /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/i;
|
|
110
|
+
const SENSITIVE_TOKEN_PATTERN = /(secret|password|api[_-]?key|token)\s*[:=]\s*[^\s]{6,}/i;
|
|
111
|
+
const AWS_KEY_PATTERN = /AKIA[0-9A-Z]{16}/;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createSchedulerPlan, finalizeSchedulerPlan, serializeSchedulerPlan, buildSchedulerRunSummary } from './plan.js';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export function createSchedulerPlan(request, options = {}) {
|
|
2
|
+
const now = options.now ?? (() => new Date());
|
|
3
|
+
const requestedAt = now().toISOString();
|
|
4
|
+
const prefix = options.instancePrefix ?? request.task.id.toLowerCase();
|
|
5
|
+
const groupSize = extractTfgrpoGroupSize(request);
|
|
6
|
+
const recovery = {
|
|
7
|
+
heartbeatIntervalSeconds: request.schedule.recovery.heartbeatIntervalSeconds,
|
|
8
|
+
missingHeartbeatTimeoutSeconds: request.schedule.recovery.missingHeartbeatTimeoutSeconds,
|
|
9
|
+
maxRetries: request.schedule.recovery.maxRetries
|
|
10
|
+
};
|
|
11
|
+
const slots = buildCapabilitySlots(request);
|
|
12
|
+
const { minInstances, maxInstances } = request.schedule;
|
|
13
|
+
if (minInstances > maxInstances) {
|
|
14
|
+
throw new Error(`Scheduler minInstances (${minInstances}) cannot exceed maxInstances (${maxInstances}).`);
|
|
15
|
+
}
|
|
16
|
+
if (minInstances > slots.length) {
|
|
17
|
+
const capacity = slots.length;
|
|
18
|
+
const slotLabel = capacity === 1 ? 'slot' : 'slots';
|
|
19
|
+
throw new Error(`Scheduler requires at least ${minInstances} instances but only ${capacity} fan-out ${slotLabel} ` +
|
|
20
|
+
'are available. Adjust the schedule fanOut or lower minInstances.');
|
|
21
|
+
}
|
|
22
|
+
const targetCount = Math.max(minInstances, Math.min(maxInstances, slots.length));
|
|
23
|
+
const assignments = [];
|
|
24
|
+
const capabilityCounters = new Map();
|
|
25
|
+
for (let i = 0; i < targetCount; i += 1) {
|
|
26
|
+
const slot = slots[i];
|
|
27
|
+
const counter = (capabilityCounters.get(slot.capability) ?? 0) + 1;
|
|
28
|
+
capabilityCounters.set(slot.capability, counter);
|
|
29
|
+
const instanceId = `${prefix}-${slot.capability}-${String(counter).padStart(2, '0')}`;
|
|
30
|
+
const attempt = {
|
|
31
|
+
number: 1,
|
|
32
|
+
assignedAt: requestedAt,
|
|
33
|
+
startedAt: null,
|
|
34
|
+
completedAt: null,
|
|
35
|
+
status: 'pending',
|
|
36
|
+
recoveryCheckpoints: []
|
|
37
|
+
};
|
|
38
|
+
const groupIndex = groupSize && groupSize > 0 ? ((i % groupSize) + 1) : null;
|
|
39
|
+
assignments.push({
|
|
40
|
+
instanceId,
|
|
41
|
+
capability: slot.capability,
|
|
42
|
+
status: 'assigned',
|
|
43
|
+
assignedAt: requestedAt,
|
|
44
|
+
completedAt: null,
|
|
45
|
+
attempts: [attempt],
|
|
46
|
+
metadata: {
|
|
47
|
+
weight: slot.weight,
|
|
48
|
+
maxConcurrency: slot.maxConcurrency,
|
|
49
|
+
...(groupIndex ? { groupIndex } : {})
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
mode: 'multi-instance',
|
|
55
|
+
requestedAt,
|
|
56
|
+
minInstances: request.schedule.minInstances,
|
|
57
|
+
maxInstances: request.schedule.maxInstances,
|
|
58
|
+
recovery,
|
|
59
|
+
assignments
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function finalizeSchedulerPlan(plan, finalStatus, timestamp) {
|
|
63
|
+
const attemptStatus = finalStatus === 'succeeded' ? 'completed' : finalStatus === 'running' ? 'running' : 'failed';
|
|
64
|
+
const isTerminal = finalStatus !== 'running';
|
|
65
|
+
for (const assignment of plan.assignments) {
|
|
66
|
+
assignment.status = finalStatus;
|
|
67
|
+
if (isTerminal) {
|
|
68
|
+
assignment.completedAt = timestamp;
|
|
69
|
+
}
|
|
70
|
+
const latest = assignment.attempts[assignment.attempts.length - 1];
|
|
71
|
+
if (latest) {
|
|
72
|
+
if (!latest.startedAt) {
|
|
73
|
+
latest.startedAt = timestamp;
|
|
74
|
+
}
|
|
75
|
+
if (isTerminal) {
|
|
76
|
+
latest.completedAt = timestamp;
|
|
77
|
+
}
|
|
78
|
+
latest.status = attemptStatus;
|
|
79
|
+
if (attemptStatus === 'failed') {
|
|
80
|
+
latest.recoveryCheckpoints.push({
|
|
81
|
+
timestamp,
|
|
82
|
+
reason: 'missed-heartbeat',
|
|
83
|
+
action: 'requeue',
|
|
84
|
+
detail: 'Finalizer detected incomplete execution; queued for recovery.'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function serializeSchedulerPlan(plan) {
|
|
91
|
+
return {
|
|
92
|
+
mode: plan.mode,
|
|
93
|
+
requested_at: plan.requestedAt,
|
|
94
|
+
min_instances: plan.minInstances,
|
|
95
|
+
max_instances: plan.maxInstances,
|
|
96
|
+
recovery: {
|
|
97
|
+
heartbeat_interval_seconds: plan.recovery.heartbeatIntervalSeconds,
|
|
98
|
+
missing_heartbeat_timeout_seconds: plan.recovery.missingHeartbeatTimeoutSeconds,
|
|
99
|
+
max_retries: plan.recovery.maxRetries
|
|
100
|
+
},
|
|
101
|
+
assignments: plan.assignments.map(serializeAssignment)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export function buildSchedulerRunSummary(plan) {
|
|
105
|
+
const assignments = plan.assignments.map((assignment) => ({
|
|
106
|
+
instanceId: assignment.instanceId,
|
|
107
|
+
capability: assignment.capability,
|
|
108
|
+
status: assignment.status,
|
|
109
|
+
attempts: assignment.attempts.length,
|
|
110
|
+
lastCompletedAt: assignment.completedAt
|
|
111
|
+
}));
|
|
112
|
+
return {
|
|
113
|
+
mode: plan.mode,
|
|
114
|
+
recovery: plan.recovery,
|
|
115
|
+
assignments
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function buildCapabilitySlots(request) {
|
|
119
|
+
const slots = [];
|
|
120
|
+
for (const entry of request.schedule.fanOut) {
|
|
121
|
+
const maxConcurrency = Math.max(1, entry.maxConcurrency ?? 1);
|
|
122
|
+
for (let index = 0; index < maxConcurrency; index += 1) {
|
|
123
|
+
slots.push({
|
|
124
|
+
capability: entry.capability,
|
|
125
|
+
weight: entry.weight,
|
|
126
|
+
maxConcurrency
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (slots.length === 0) {
|
|
131
|
+
slots.push({ capability: 'general', weight: 1, maxConcurrency: 1 });
|
|
132
|
+
}
|
|
133
|
+
slots.sort((a, b) => b.weight - a.weight);
|
|
134
|
+
return slots;
|
|
135
|
+
}
|
|
136
|
+
function serializeAssignment(assignment) {
|
|
137
|
+
return {
|
|
138
|
+
instance_id: assignment.instanceId,
|
|
139
|
+
capability: assignment.capability,
|
|
140
|
+
status: assignment.status,
|
|
141
|
+
assigned_at: assignment.assignedAt,
|
|
142
|
+
completed_at: assignment.completedAt,
|
|
143
|
+
metadata: assignment.metadata,
|
|
144
|
+
attempts: assignment.attempts.map(serializeAttempt)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function serializeAttempt(attempt) {
|
|
148
|
+
return {
|
|
149
|
+
number: attempt.number,
|
|
150
|
+
assigned_at: attempt.assignedAt,
|
|
151
|
+
started_at: attempt.startedAt ?? null,
|
|
152
|
+
completed_at: attempt.completedAt ?? null,
|
|
153
|
+
status: attempt.status,
|
|
154
|
+
recovery_checkpoints: attempt.recoveryCheckpoints
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function extractTfgrpoGroupSize(request) {
|
|
158
|
+
const metadata = request.metadata;
|
|
159
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const tfgrpo = metadata.tfgrpo;
|
|
163
|
+
if (!tfgrpo || typeof tfgrpo !== 'object') {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const value = tfgrpo.groupSize;
|
|
167
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return Math.trunc(value);
|
|
171
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|