@kbediako/codex-orchestrator 0.1.2 → 0.1.4
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/README.md +15 -8
- package/dist/bin/codex-orchestrator.js +252 -121
- package/dist/orchestrator/src/cli/config/delegationConfig.js +485 -0
- package/dist/orchestrator/src/cli/config/userConfig.js +86 -12
- package/dist/orchestrator/src/cli/control/confirmations.js +262 -0
- package/dist/orchestrator/src/cli/control/controlServer.js +1476 -0
- package/dist/orchestrator/src/cli/control/controlState.js +46 -0
- package/dist/orchestrator/src/cli/control/controlWatcher.js +222 -0
- package/dist/orchestrator/src/cli/control/delegationTokens.js +62 -0
- package/dist/orchestrator/src/cli/control/questions.js +106 -0
- package/dist/orchestrator/src/cli/delegationServer.js +1368 -0
- package/dist/orchestrator/src/cli/events/runEventStream.js +246 -0
- package/dist/orchestrator/src/cli/exec/context.js +9 -3
- package/dist/orchestrator/src/cli/exec/learning.js +5 -3
- package/dist/orchestrator/src/cli/exec/stageRunner.js +30 -5
- package/dist/orchestrator/src/cli/exec/summary.js +1 -1
- package/dist/orchestrator/src/cli/metrics/metricsAggregator.js +377 -147
- package/dist/orchestrator/src/cli/metrics/metricsRecorder.js +3 -5
- package/dist/orchestrator/src/cli/orchestrator.js +233 -47
- package/dist/orchestrator/src/cli/pipelines/index.js +13 -24
- package/dist/orchestrator/src/cli/rlm/prompt.js +31 -0
- package/dist/orchestrator/src/cli/rlm/runner.js +177 -0
- package/dist/orchestrator/src/cli/rlm/types.js +1 -0
- package/dist/orchestrator/src/cli/rlm/validator.js +159 -0
- package/dist/orchestrator/src/cli/rlmRunner.js +440 -0
- package/dist/orchestrator/src/cli/run/environment.js +4 -11
- package/dist/orchestrator/src/cli/run/manifest.js +7 -1
- package/dist/orchestrator/src/cli/run/manifestPersister.js +33 -3
- package/dist/orchestrator/src/cli/run/runPaths.js +14 -0
- package/dist/orchestrator/src/cli/services/commandRunner.js +2 -2
- package/dist/orchestrator/src/cli/services/controlPlaneService.js +3 -1
- package/dist/orchestrator/src/cli/services/execRuntime.js +1 -2
- package/dist/orchestrator/src/cli/services/pipelineResolver.js +33 -2
- package/dist/orchestrator/src/cli/services/runPreparation.js +7 -1
- package/dist/orchestrator/src/cli/services/schedulerService.js +1 -1
- package/dist/orchestrator/src/cli/utils/devtools.js +33 -2
- package/dist/orchestrator/src/cli/utils/specGuardRunner.js +3 -1
- package/dist/orchestrator/src/cli/utils/strings.js +8 -6
- package/dist/orchestrator/src/persistence/ExperienceStore.js +115 -58
- package/dist/orchestrator/src/persistence/PersistenceCoordinator.js +8 -8
- package/dist/orchestrator/src/persistence/TaskStateStore.js +3 -2
- package/dist/orchestrator/src/persistence/lockFile.js +26 -1
- package/dist/orchestrator/src/persistence/sanitizeIdentifier.js +1 -1
- package/dist/orchestrator/src/sync/CloudSyncWorker.js +17 -4
- package/dist/packages/orchestrator/src/exec/stdio.js +112 -0
- package/dist/packages/orchestrator/src/exec/unified-exec.js +1 -1
- package/dist/packages/orchestrator/src/index.js +1 -0
- package/dist/packages/orchestrator/src/telemetry/otel-exporter.js +21 -0
- package/dist/packages/shared/design-artifacts/writer.js +4 -14
- package/dist/packages/shared/streams/stdio.js +2 -112
- package/dist/packages/shared/utils/strings.js +17 -0
- package/dist/scripts/design/pipeline/advanced-assets.js +1 -1
- package/dist/scripts/design/pipeline/context.js +5 -5
- package/dist/scripts/design/pipeline/extract.js +9 -6
- package/dist/scripts/design/pipeline/{optionalDeps.js → optional-deps.js} +49 -38
- package/dist/scripts/design/pipeline/permit.js +59 -0
- package/dist/scripts/design/pipeline/toolkit/common.js +18 -32
- package/dist/scripts/design/pipeline/toolkit/reference.js +1 -1
- package/dist/scripts/design/pipeline/toolkit/snapshot.js +1 -1
- package/dist/scripts/design/pipeline/visual-regression.js +2 -11
- package/dist/scripts/lib/cli-args.js +53 -0
- package/dist/scripts/lib/docs-helpers.js +111 -0
- package/dist/scripts/lib/npm-pack.js +20 -0
- package/dist/scripts/lib/run-manifests.js +160 -0
- package/package.json +7 -2
- package/dist/orchestrator/src/cli/pipelines/defaultDiagnostics.js +0 -32
- package/dist/orchestrator/src/cli/pipelines/designReference.js +0 -72
- package/dist/orchestrator/src/cli/pipelines/hiFiDesignToolkit.js +0 -71
- package/dist/orchestrator/src/cli/utils/jsonlWriter.js +0 -10
- package/dist/orchestrator/src/control-plane/index.js +0 -3
- package/dist/orchestrator/src/persistence/identifierGuards.js +0 -1
- package/dist/orchestrator/src/persistence/writeAtomicFile.js +0 -4
- package/dist/orchestrator/src/scheduler/index.js +0 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export class ControlStateStore {
|
|
2
|
+
now;
|
|
3
|
+
state;
|
|
4
|
+
constructor(options) {
|
|
5
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
6
|
+
this.state = {
|
|
7
|
+
run_id: options.runId,
|
|
8
|
+
control_seq: options.controlSeq ?? 0,
|
|
9
|
+
latest_action: options.latestAction ?? null,
|
|
10
|
+
feature_toggles: options.featureToggles ?? null
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
updateAction(input) {
|
|
14
|
+
this.state.control_seq += 1;
|
|
15
|
+
this.state.latest_action = {
|
|
16
|
+
request_id: input.requestId ?? null,
|
|
17
|
+
requested_by: input.requestedBy,
|
|
18
|
+
requested_at: this.now(),
|
|
19
|
+
action: input.action,
|
|
20
|
+
reason: input.reason ?? null
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
updateFeatureToggles(toggles) {
|
|
24
|
+
const current = this.state.feature_toggles ?? {};
|
|
25
|
+
this.state.feature_toggles = mergeObjects(current, toggles);
|
|
26
|
+
}
|
|
27
|
+
snapshot() {
|
|
28
|
+
return structuredClone(this.state);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function mergeObjects(base, update) {
|
|
32
|
+
const merged = { ...base };
|
|
33
|
+
for (const [key, value] of Object.entries(update)) {
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
merged[key] = [...value];
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
39
|
+
const current = merged[key] ?? {};
|
|
40
|
+
merged[key] = mergeObjects(current, value);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
merged[key] = value;
|
|
44
|
+
}
|
|
45
|
+
return merged;
|
|
46
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isoTimestamp } from '../utils/time.js';
|
|
3
|
+
import { appendSummary } from '../run/manifest.js';
|
|
4
|
+
import { logger } from '../../logger.js';
|
|
5
|
+
export class ControlWatcher {
|
|
6
|
+
paths;
|
|
7
|
+
manifest;
|
|
8
|
+
persist;
|
|
9
|
+
eventStream;
|
|
10
|
+
onEntry;
|
|
11
|
+
now;
|
|
12
|
+
pollIntervalMs;
|
|
13
|
+
lastControlSeq = 0;
|
|
14
|
+
paused = false;
|
|
15
|
+
lastPauseRequestId = null;
|
|
16
|
+
lastPauseReason = null;
|
|
17
|
+
cancelRequested = false;
|
|
18
|
+
failureRequested = false;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.paths = options.paths;
|
|
21
|
+
this.manifest = options.manifest;
|
|
22
|
+
this.persist = options.persist;
|
|
23
|
+
this.eventStream = options.eventStream;
|
|
24
|
+
this.onEntry = options.onEntry;
|
|
25
|
+
this.now = options.now ?? isoTimestamp;
|
|
26
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
27
|
+
}
|
|
28
|
+
isCanceled() {
|
|
29
|
+
return this.cancelRequested;
|
|
30
|
+
}
|
|
31
|
+
isFailed() {
|
|
32
|
+
return this.failureRequested;
|
|
33
|
+
}
|
|
34
|
+
async sync() {
|
|
35
|
+
const snapshot = await readControlFile(this.paths.controlPath);
|
|
36
|
+
if (!snapshot) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const controlSeq = snapshot.control_seq ?? 0;
|
|
40
|
+
if (controlSeq <= this.lastControlSeq) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.lastControlSeq = controlSeq;
|
|
44
|
+
const latest = snapshot.latest_action;
|
|
45
|
+
if (latest &&
|
|
46
|
+
typeof latest === 'object' &&
|
|
47
|
+
(Object.prototype.hasOwnProperty.call(latest, 'confirm_nonce') ||
|
|
48
|
+
Object.prototype.hasOwnProperty.call(latest, 'confirmNonce'))) {
|
|
49
|
+
await this.safeAppend({
|
|
50
|
+
event: 'security_violation',
|
|
51
|
+
actor: 'runner',
|
|
52
|
+
payload: {
|
|
53
|
+
kind: 'confirm_nonce_present',
|
|
54
|
+
summary: 'confirm_nonce present in control action',
|
|
55
|
+
severity: 'high',
|
|
56
|
+
related_request_id: latest.request_id ?? null,
|
|
57
|
+
details_redacted: true
|
|
58
|
+
},
|
|
59
|
+
timestamp: this.now()
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const action = latest?.action;
|
|
64
|
+
if (!action) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (action === 'pause') {
|
|
68
|
+
await this.handlePause(snapshot);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (action === 'resume') {
|
|
72
|
+
await this.handleResume(snapshot);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (action === 'cancel') {
|
|
76
|
+
await this.handleCancel(snapshot);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (action === 'fail') {
|
|
80
|
+
await this.handleFail(snapshot);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async waitForResume() {
|
|
84
|
+
if (!this.paused) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
while (this.paused && !this.cancelRequested && !this.failureRequested) {
|
|
88
|
+
await delay(this.pollIntervalMs);
|
|
89
|
+
await this.sync();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async handlePause(snapshot) {
|
|
93
|
+
const nextRequestId = snapshot.latest_action?.request_id ?? null;
|
|
94
|
+
const nextReason = snapshot.latest_action?.reason ?? null;
|
|
95
|
+
if (this.paused && nextRequestId === this.lastPauseRequestId && nextReason === this.lastPauseReason) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const wasPaused = this.paused;
|
|
99
|
+
this.paused = true;
|
|
100
|
+
this.lastPauseRequestId = nextRequestId;
|
|
101
|
+
this.lastPauseReason = nextReason;
|
|
102
|
+
const nextDetail = nextReason ?? 'paused';
|
|
103
|
+
if (!wasPaused || this.manifest.status_detail !== nextDetail) {
|
|
104
|
+
this.manifest.status_detail = nextDetail;
|
|
105
|
+
if (!wasPaused) {
|
|
106
|
+
appendSummary(this.manifest, 'Run paused by control request.');
|
|
107
|
+
}
|
|
108
|
+
await this.persist();
|
|
109
|
+
}
|
|
110
|
+
await this.safeAppend({
|
|
111
|
+
event: 'pause_requested',
|
|
112
|
+
actor: snapshot.latest_action?.requested_by ?? 'user',
|
|
113
|
+
payload: {
|
|
114
|
+
request_id: snapshot.latest_action?.request_id ?? null,
|
|
115
|
+
control_seq: snapshot.control_seq ?? null,
|
|
116
|
+
requested_by: snapshot.latest_action?.requested_by ?? null,
|
|
117
|
+
reason: snapshot.latest_action?.reason ?? null
|
|
118
|
+
},
|
|
119
|
+
timestamp: this.now()
|
|
120
|
+
});
|
|
121
|
+
await this.safeAppend({
|
|
122
|
+
event: 'run_paused',
|
|
123
|
+
actor: 'runner',
|
|
124
|
+
payload: {
|
|
125
|
+
reason: 'control_request',
|
|
126
|
+
request_id: snapshot.latest_action?.request_id ?? null,
|
|
127
|
+
control_seq: snapshot.control_seq ?? null,
|
|
128
|
+
requested_reason: snapshot.latest_action?.reason ?? null
|
|
129
|
+
},
|
|
130
|
+
timestamp: this.now()
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async handleResume(snapshot) {
|
|
134
|
+
if (!this.paused) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.paused = false;
|
|
138
|
+
this.lastPauseRequestId = null;
|
|
139
|
+
this.lastPauseReason = null;
|
|
140
|
+
this.manifest.status_detail = null;
|
|
141
|
+
appendSummary(this.manifest, 'Run resumed by control request.');
|
|
142
|
+
await this.persist();
|
|
143
|
+
await this.safeAppend({
|
|
144
|
+
event: 'run_resumed',
|
|
145
|
+
actor: 'runner',
|
|
146
|
+
payload: {
|
|
147
|
+
request_id: snapshot.latest_action?.request_id ?? null,
|
|
148
|
+
control_seq: snapshot.control_seq ?? null,
|
|
149
|
+
requested_by: snapshot.latest_action?.requested_by ?? null,
|
|
150
|
+
requested_reason: snapshot.latest_action?.reason ?? null
|
|
151
|
+
},
|
|
152
|
+
timestamp: this.now()
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async handleCancel(snapshot) {
|
|
156
|
+
if (this.cancelRequested) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.cancelRequested = true;
|
|
160
|
+
appendSummary(this.manifest, 'Run cancellation requested.');
|
|
161
|
+
await this.persist();
|
|
162
|
+
await this.safeAppend({
|
|
163
|
+
event: 'run_canceled',
|
|
164
|
+
actor: 'runner',
|
|
165
|
+
payload: {
|
|
166
|
+
request_id: snapshot.latest_action?.request_id ?? null,
|
|
167
|
+
control_seq: snapshot.control_seq ?? null,
|
|
168
|
+
requested_by: snapshot.latest_action?.requested_by ?? null,
|
|
169
|
+
requested_reason: snapshot.latest_action?.reason ?? null
|
|
170
|
+
},
|
|
171
|
+
timestamp: this.now()
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async handleFail(snapshot) {
|
|
175
|
+
if (this.failureRequested) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.failureRequested = true;
|
|
179
|
+
this.manifest.status_detail = snapshot.latest_action?.reason ?? 'control_failed';
|
|
180
|
+
appendSummary(this.manifest, 'Run failed by control request.');
|
|
181
|
+
await this.persist();
|
|
182
|
+
await this.safeAppend({
|
|
183
|
+
event: 'run_failed',
|
|
184
|
+
actor: 'runner',
|
|
185
|
+
payload: {
|
|
186
|
+
reason: 'control_request',
|
|
187
|
+
request_id: snapshot.latest_action?.request_id ?? null,
|
|
188
|
+
control_seq: snapshot.control_seq ?? null,
|
|
189
|
+
requested_by: snapshot.latest_action?.requested_by ?? null,
|
|
190
|
+
requested_reason: snapshot.latest_action?.reason ?? null
|
|
191
|
+
},
|
|
192
|
+
timestamp: this.now()
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async safeAppend(entry) {
|
|
196
|
+
try {
|
|
197
|
+
const appended = await this.eventStream?.append(entry);
|
|
198
|
+
if (appended) {
|
|
199
|
+
this.onEntry?.(appended);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
logger.warn(`[ControlWatcher] Failed to append event: ${error?.message ?? String(error)}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function readControlFile(pathname) {
|
|
208
|
+
try {
|
|
209
|
+
const raw = await readFile(pathname, 'utf8');
|
|
210
|
+
return JSON.parse(raw);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.code === 'ENOENT') {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
logger.warn(`[ControlWatcher] Failed to read control file: ${error?.message ?? String(error)}`);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function delay(ms) {
|
|
221
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
222
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export class DelegationTokenStore {
|
|
3
|
+
now;
|
|
4
|
+
records = new Map();
|
|
5
|
+
constructor(options = {}) {
|
|
6
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
7
|
+
if (options.seed) {
|
|
8
|
+
for (const record of options.seed) {
|
|
9
|
+
this.records.set(record.token_id, { ...record });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
issue(parentRunId, childRunId) {
|
|
14
|
+
const token = randomBytes(32).toString('hex');
|
|
15
|
+
const record = {
|
|
16
|
+
token_id: `dlt-${randomBytes(8).toString('hex')}`,
|
|
17
|
+
token_hash: hashToken(token),
|
|
18
|
+
parent_run_id: parentRunId,
|
|
19
|
+
child_run_id: childRunId,
|
|
20
|
+
created_at: this.now(),
|
|
21
|
+
expires_at: null
|
|
22
|
+
};
|
|
23
|
+
this.records.set(record.token_id, record);
|
|
24
|
+
return { token, record };
|
|
25
|
+
}
|
|
26
|
+
register(token, parentRunId, childRunId) {
|
|
27
|
+
const record = {
|
|
28
|
+
token_id: `dlt-${randomBytes(8).toString('hex')}`,
|
|
29
|
+
token_hash: hashToken(token),
|
|
30
|
+
parent_run_id: parentRunId,
|
|
31
|
+
child_run_id: childRunId,
|
|
32
|
+
created_at: this.now(),
|
|
33
|
+
expires_at: null
|
|
34
|
+
};
|
|
35
|
+
this.records.set(record.token_id, record);
|
|
36
|
+
return { ...record };
|
|
37
|
+
}
|
|
38
|
+
validate(token, parentRunId, childRunId) {
|
|
39
|
+
const tokenHash = hashToken(token);
|
|
40
|
+
for (const record of this.records.values()) {
|
|
41
|
+
if (record.parent_run_id !== parentRunId || record.child_run_id !== childRunId) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (timingSafeEqualStrings(record.token_hash, tokenHash)) {
|
|
45
|
+
return { ...record };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
list() {
|
|
51
|
+
return Array.from(this.records.values()).map((record) => ({ ...record }));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function hashToken(token) {
|
|
55
|
+
return createHash('sha256').update(token).digest('hex');
|
|
56
|
+
}
|
|
57
|
+
function timingSafeEqualStrings(left, right) {
|
|
58
|
+
if (left.length !== right.length) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return timingSafeEqual(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
|
62
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export class QuestionQueue {
|
|
2
|
+
now;
|
|
3
|
+
records = new Map();
|
|
4
|
+
counter = 0;
|
|
5
|
+
constructor(options = {}) {
|
|
6
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
7
|
+
if (options.seed) {
|
|
8
|
+
this.hydrate(options.seed);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
enqueue(input) {
|
|
12
|
+
const queuedAt = this.now();
|
|
13
|
+
const expiresAt = typeof input.expiresInMs === 'number' && input.expiresInMs > 0
|
|
14
|
+
? new Date(Date.parse(queuedAt) + input.expiresInMs).toISOString()
|
|
15
|
+
: null;
|
|
16
|
+
const question = {
|
|
17
|
+
question_id: this.nextId(),
|
|
18
|
+
parent_run_id: input.parentRunId,
|
|
19
|
+
from_run_id: input.fromRunId,
|
|
20
|
+
from_manifest_path: input.fromManifestPath ?? null,
|
|
21
|
+
prompt: input.prompt,
|
|
22
|
+
urgency: input.urgency,
|
|
23
|
+
status: 'queued',
|
|
24
|
+
queued_at: queuedAt,
|
|
25
|
+
expires_at: expiresAt,
|
|
26
|
+
expires_in_ms: typeof input.expiresInMs === 'number' ? input.expiresInMs : null,
|
|
27
|
+
auto_pause: input.autoPause ?? true,
|
|
28
|
+
expiry_fallback: input.expiryFallback ?? null
|
|
29
|
+
};
|
|
30
|
+
this.records.set(question.question_id, question);
|
|
31
|
+
return question;
|
|
32
|
+
}
|
|
33
|
+
get(questionId) {
|
|
34
|
+
return this.records.get(questionId);
|
|
35
|
+
}
|
|
36
|
+
list() {
|
|
37
|
+
return Array.from(this.records.values());
|
|
38
|
+
}
|
|
39
|
+
answer(questionId, answer, answeredBy) {
|
|
40
|
+
const record = this.records.get(questionId);
|
|
41
|
+
if (!record) {
|
|
42
|
+
throw new Error('question_not_found');
|
|
43
|
+
}
|
|
44
|
+
if (record.status !== 'queued') {
|
|
45
|
+
throw new Error('question_closed');
|
|
46
|
+
}
|
|
47
|
+
const now = this.now();
|
|
48
|
+
record.status = 'answered';
|
|
49
|
+
record.answer = answer;
|
|
50
|
+
record.answered_by = answeredBy;
|
|
51
|
+
record.answered_at = now;
|
|
52
|
+
record.closed_at = now;
|
|
53
|
+
}
|
|
54
|
+
dismiss(questionId, dismissedBy) {
|
|
55
|
+
const record = this.records.get(questionId);
|
|
56
|
+
if (!record) {
|
|
57
|
+
throw new Error('question_not_found');
|
|
58
|
+
}
|
|
59
|
+
if (record.status !== 'queued') {
|
|
60
|
+
throw new Error('question_closed');
|
|
61
|
+
}
|
|
62
|
+
const now = this.now();
|
|
63
|
+
record.status = 'dismissed';
|
|
64
|
+
record.dismissed_by = dismissedBy;
|
|
65
|
+
record.closed_at = now;
|
|
66
|
+
}
|
|
67
|
+
expire() {
|
|
68
|
+
const nowIso = this.now();
|
|
69
|
+
const now = Date.parse(nowIso);
|
|
70
|
+
const expired = [];
|
|
71
|
+
for (const record of this.records.values()) {
|
|
72
|
+
if (record.status !== 'queued' || !record.expires_at) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (Date.parse(record.expires_at) <= now) {
|
|
76
|
+
record.status = 'expired';
|
|
77
|
+
record.closed_at = nowIso;
|
|
78
|
+
expired.push({ ...record });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return expired;
|
|
82
|
+
}
|
|
83
|
+
hydrate(records) {
|
|
84
|
+
for (const record of records) {
|
|
85
|
+
this.records.set(record.question_id, { ...record });
|
|
86
|
+
}
|
|
87
|
+
this.counter = Math.max(this.counter, resolveCounter(records));
|
|
88
|
+
}
|
|
89
|
+
nextId() {
|
|
90
|
+
this.counter += 1;
|
|
91
|
+
return `q-${this.counter.toString().padStart(4, '0')}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function resolveCounter(records) {
|
|
95
|
+
let max = 0;
|
|
96
|
+
for (const record of records) {
|
|
97
|
+
const match = /^q-(\d+)/.exec(record.question_id);
|
|
98
|
+
if (match) {
|
|
99
|
+
const value = Number.parseInt(match[1] ?? '0', 10);
|
|
100
|
+
if (Number.isFinite(value)) {
|
|
101
|
+
max = Math.max(max, value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return max;
|
|
106
|
+
}
|