@ornexus/neocortex 4.60.41 → 4.60.43
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/dist/sbom.cdx.json +31 -31
- package/install.ps1 +1 -1
- package/install.sh +1 -1
- package/package.json +14 -2
- package/packages/client/dist/commands/invoke.js +57 -56
- package/packages/client/dist/generated/command-registry.generated.d.ts +66 -23
- package/packages/client/dist/generated/command-registry.generated.js +1 -1
- package/packages/client/dist/generated/public-projections.generated.d.ts +100 -0
- package/packages/client/dist/generated/public-projections.generated.js +1 -0
- package/packages/client/dist/git/git-lifecycle-store.d.ts +17 -0
- package/packages/client/dist/git/git-lifecycle-store.js +4 -4
- package/packages/client/dist/index.d.ts +1 -0
- package/packages/client/dist/index.js +1 -1
- package/packages/client/dist/migrations/project-identity.js +7 -7
- package/packages/client/dist/migrations/state-json.d.ts +14 -0
- package/packages/client/dist/migrations/state-json.js +4 -0
- package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.d.ts +2 -0
- package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +1 -1
- package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +8 -8
- package/packages/client/dist/runner/headless/run-yoloop.d.ts +7 -1
- package/packages/client/dist/runner/headless/run-yoloop.js +2 -2
- package/packages/client/dist/state/project-state-snapshot.js +1 -1
- package/packages/client/dist/state/state-json-repair.js +3 -3
- package/packages/client/dist/yoloop/invoke-hooks.d.ts +2 -3
- package/packages/client/dist/yoloop/invoke-hooks.js +9 -8
- package/packages/client/dist/yoloop/shared-discover-epics.d.ts +3 -3
- package/packages/client/dist/yoloop/shared-discover-epics.js +1 -1
- package/packages/client/dist/yoloop/shared-yoloop-types.d.ts +87 -1
- package/packages/client/dist/yoloop/yoloop-client-state-store.d.ts +27 -3
- package/packages/client/dist/yoloop/yoloop-client-state-store.js +1 -1
- package/packages/shared/dist/command-registry-contract.js +38 -0
- package/packages/shared/dist/continuity/goal-operation-authority.js +48 -0
- package/packages/shared/dist/executable-graph-catalog/contracts.js +935 -0
- package/packages/shared/dist/generated/command-registry.generated.js +82 -23
- package/packages/shared/dist/generated/invocation-schema.generated.js +12 -4
- package/packages/shared/dist/generated/public-projections.generated.js +149 -0
- package/packages/shared/dist/invocation/executable-policy-recovery.d.ts +83 -0
- package/packages/shared/dist/invocation/executable-policy-recovery.js +272 -0
- package/packages/shared/dist/invocation/index.d.ts +3 -1
- package/packages/shared/dist/invocation/index.js +40 -1
- package/packages/shared/dist/invocation/recovery-route-compiler.d.ts +56 -0
- package/packages/shared/dist/invocation/recovery-route-compiler.js +214 -0
- package/packages/shared/dist/state/index.d.ts +1 -0
- package/packages/shared/dist/state/index.js +1 -0
- package/packages/shared/dist/state/state-migration.d.ts +58 -0
- package/packages/shared/dist/state/state-migration.js +404 -0
- package/packages/shared/dist/yoloop/canonical.js +15 -0
- package/packages/shared/dist/yoloop/operational-budget.js +164 -0
- package/packages/shared/dist/yoloop/recovery-types.js +39 -0
- package/targets-stubs/antigravity/gemini.md +1 -1
- package/targets-stubs/antigravity/skill/SKILL.md +1 -1
- package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex-root.md +2 -2
- package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
- package/targets-stubs/claude-code/neocortex.md +2 -2
- package/targets-stubs/codex/AGENTS.md +11 -2
- package/targets-stubs/codex/README.md +7 -4
- package/targets-stubs/codex/neocortex.toml +53 -6
- package/targets-stubs/cursor/agent.md +2 -2
- package/targets-stubs/gemini-cli/agent.md +2 -2
- package/targets-stubs/opencode/neocortex-root.md +1 -1
- package/targets-stubs/vscode/neocortex.agent.md +2 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export declare const STATE_MIGRATION_PLAN_SCHEMA: "StateMigrationPlan/1.0";
|
|
2
|
+
export declare const STATE_MIGRATION_RECEIPT_SCHEMA: "StateMigrationReceipt/1.0";
|
|
3
|
+
export type StateMigrationHardStopReason = 'state_not_object' | 'state_not_json' | 'state_projected_or_partial' | 'unsupported_schema_version' | 'invalid_epics_collection' | 'invalid_stories_collection' | 'invalid_epic_record' | 'invalid_story_record' | 'record_identity_conflict' | 'invalid_planning_container' | 'invalid_planning_current' | 'invalid_planning_history' | 'legacy_epic_id_missing' | 'legacy_story_shape_invalid' | 'legacy_story_id_missing' | 'migration_value_conflict';
|
|
4
|
+
export declare class StateMigrationHardStopError extends Error {
|
|
5
|
+
readonly reasonCode: StateMigrationHardStopReason;
|
|
6
|
+
constructor(reasonCode: StateMigrationHardStopReason, message?: string);
|
|
7
|
+
}
|
|
8
|
+
export interface StateMigrationStepV1 {
|
|
9
|
+
readonly kind: 'initialize-collection' | 'merge-epic' | 'merge-story' | 'preserve-legacy' | 'remove-legacy' | 'set-schema-version';
|
|
10
|
+
readonly target: string;
|
|
11
|
+
readonly source?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface StateMigrationPlanV1 {
|
|
14
|
+
readonly schemaVersion: typeof STATE_MIGRATION_PLAN_SCHEMA;
|
|
15
|
+
readonly operationId: string;
|
|
16
|
+
readonly sourceSchemaVersion: 0 | 1 | 'absent';
|
|
17
|
+
readonly targetSchemaVersion: 1;
|
|
18
|
+
readonly sourceCanonicalHash: string;
|
|
19
|
+
readonly targetCanonicalHash: string;
|
|
20
|
+
readonly targetState: Readonly<Record<string, unknown>>;
|
|
21
|
+
readonly steps: readonly StateMigrationStepV1[];
|
|
22
|
+
readonly migratedEpicIds: readonly string[];
|
|
23
|
+
readonly migratedStoryIds: readonly string[];
|
|
24
|
+
readonly preservedUnknownFieldCount: number;
|
|
25
|
+
readonly reasonCodes: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export type StateMigrationDecision = {
|
|
28
|
+
readonly kind: 'unchanged';
|
|
29
|
+
readonly sourceCanonicalHash: string;
|
|
30
|
+
readonly state: Readonly<Record<string, unknown>>;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: 'migrate';
|
|
33
|
+
readonly plan: StateMigrationPlanV1;
|
|
34
|
+
};
|
|
35
|
+
export interface StateMigrationReceiptV1 {
|
|
36
|
+
readonly schemaVersion: typeof STATE_MIGRATION_RECEIPT_SCHEMA;
|
|
37
|
+
readonly operationId: string;
|
|
38
|
+
readonly outcome: 'applied' | 'already-applied' | 'blocked';
|
|
39
|
+
readonly sourceBytesHash: string;
|
|
40
|
+
readonly targetBytesHash: string;
|
|
41
|
+
readonly sourceCanonicalHash: string;
|
|
42
|
+
readonly targetCanonicalHash: string;
|
|
43
|
+
readonly backupHash: string | null;
|
|
44
|
+
readonly rollbackStatus: 'not-required' | 'restored' | 'unverified';
|
|
45
|
+
readonly migratedEpicCount: number;
|
|
46
|
+
readonly migratedStoryCount: number;
|
|
47
|
+
readonly preservedUnknownFieldCount: number;
|
|
48
|
+
readonly reasonCodes: readonly string[];
|
|
49
|
+
readonly publicSafe: true;
|
|
50
|
+
}
|
|
51
|
+
export declare function stateMigrationDigest(value: unknown): string;
|
|
52
|
+
/**
|
|
53
|
+
* Build a deterministic, no-I/O migration plan. Every destructive decision is
|
|
54
|
+
* preceded by complete structural validation and a lossless canonical merge.
|
|
55
|
+
*/
|
|
56
|
+
export declare function planStateMigration(state: unknown): StateMigrationDecision;
|
|
57
|
+
export declare function assertStateMigrationPlanV1(value: unknown): asserts value is StateMigrationPlanV1;
|
|
58
|
+
export declare function assertStateMigrationReceiptV1(value: unknown): asserts value is StateMigrationReceiptV1;
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const STATE_MIGRATION_PLAN_SCHEMA = 'StateMigrationPlan/1.0';
|
|
3
|
+
export const STATE_MIGRATION_RECEIPT_SCHEMA = 'StateMigrationReceipt/1.0';
|
|
4
|
+
export class StateMigrationHardStopError extends Error {
|
|
5
|
+
reasonCode;
|
|
6
|
+
constructor(reasonCode, message = reasonCode) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.reasonCode = reasonCode;
|
|
9
|
+
this.name = 'StateMigrationHardStopError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function isPlainObject(value) {
|
|
13
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
14
|
+
return false;
|
|
15
|
+
const prototype = Object.getPrototypeOf(value);
|
|
16
|
+
return prototype === Object.prototype || prototype === null;
|
|
17
|
+
}
|
|
18
|
+
function clone(value) {
|
|
19
|
+
return JSON.parse(JSON.stringify(value));
|
|
20
|
+
}
|
|
21
|
+
function assertJsonValue(value, seen = new WeakSet()) {
|
|
22
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
23
|
+
return;
|
|
24
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
25
|
+
return;
|
|
26
|
+
if (typeof value !== 'object')
|
|
27
|
+
throw new StateMigrationHardStopError('state_not_json');
|
|
28
|
+
if (seen.has(value))
|
|
29
|
+
throw new StateMigrationHardStopError('state_not_json');
|
|
30
|
+
seen.add(value);
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
for (const item of value)
|
|
33
|
+
assertJsonValue(item, seen);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (!isPlainObject(value))
|
|
37
|
+
throw new StateMigrationHardStopError('state_not_json');
|
|
38
|
+
for (const item of Object.values(value))
|
|
39
|
+
assertJsonValue(item, seen);
|
|
40
|
+
}
|
|
41
|
+
seen.delete(value);
|
|
42
|
+
}
|
|
43
|
+
function canonicalize(value) {
|
|
44
|
+
if (value === null || typeof value !== 'object')
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return `[${value.map(canonicalize).join(',')}]`;
|
|
48
|
+
const object = value;
|
|
49
|
+
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(object[key])}`).join(',')}}`;
|
|
50
|
+
}
|
|
51
|
+
export function stateMigrationDigest(value) {
|
|
52
|
+
return createHash('sha256')
|
|
53
|
+
.update('Neocortex/StateMigrationCanonicalJSON/1\0')
|
|
54
|
+
.update(canonicalize(value))
|
|
55
|
+
.digest('hex');
|
|
56
|
+
}
|
|
57
|
+
function countObjectFields(value) {
|
|
58
|
+
if (!isPlainObject(value))
|
|
59
|
+
return 0;
|
|
60
|
+
return Object.entries(value).reduce((count, [, child]) => count + 1 + countObjectFields(child), 0);
|
|
61
|
+
}
|
|
62
|
+
function preservedUnknownFieldCount(state) {
|
|
63
|
+
const rootKnown = new Set(['schema_version', 'config', 'project', 'epics', 'stories', 'planning']);
|
|
64
|
+
let count = Object.entries(state)
|
|
65
|
+
.filter(([key]) => !rootKnown.has(key))
|
|
66
|
+
.reduce((total, [, value]) => total + 1 + countObjectFields(value), 0);
|
|
67
|
+
const planning = isPlainObject(state.planning) ? state.planning : undefined;
|
|
68
|
+
if (planning) {
|
|
69
|
+
count += Object.entries(planning)
|
|
70
|
+
.filter(([key]) => key !== 'current' && key !== 'history')
|
|
71
|
+
.reduce((total, [, value]) => total + 1 + countObjectFields(value), 0);
|
|
72
|
+
}
|
|
73
|
+
const epicKnown = new Set(['epic_id', 'id', 'title', 'status', 'stories', 'created_at', 'updated_at', 'completed_at', 'completed_stories', 'total_stories']);
|
|
74
|
+
const storyKnown = new Set(['id', 'epic_id', 'title', 'status', 'depends_on', 'created_at', 'updated_at', 'completed_at']);
|
|
75
|
+
const countRecordUnknown = (record, known) => isPlainObject(record)
|
|
76
|
+
? Object.entries(record).filter(([key]) => !known.has(key)).reduce((total, [, value]) => total + 1 + countObjectFields(value), 0)
|
|
77
|
+
: 0;
|
|
78
|
+
if (isPlainObject(planning?.current)) {
|
|
79
|
+
count += countRecordUnknown(planning.current, epicKnown);
|
|
80
|
+
if (Array.isArray(planning.current.stories)) {
|
|
81
|
+
count += planning.current.stories.reduce((total, story) => total + countRecordUnknown(story, storyKnown), 0);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(planning?.history)) {
|
|
85
|
+
count += planning.history.reduce((total, epic) => total + countRecordUnknown(epic, epicKnown), 0);
|
|
86
|
+
}
|
|
87
|
+
return count;
|
|
88
|
+
}
|
|
89
|
+
function equal(left, right) {
|
|
90
|
+
return canonicalize(left) === canonicalize(right);
|
|
91
|
+
}
|
|
92
|
+
function assertCompleteState(input) {
|
|
93
|
+
const scope = input.scope;
|
|
94
|
+
if (isPlainObject(scope)) {
|
|
95
|
+
const omittedStories = typeof scope.omittedStoryCount === 'number' ? scope.omittedStoryCount : 0;
|
|
96
|
+
const omittedEpics = typeof scope.omittedEpicCount === 'number' ? scope.omittedEpicCount : 0;
|
|
97
|
+
if (scope.kind === 'projected' || omittedStories > 0 || omittedEpics > 0) {
|
|
98
|
+
throw new StateMigrationHardStopError('state_projected_or_partial');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function validateCanonicalCollection(value, collection) {
|
|
103
|
+
if (value === undefined)
|
|
104
|
+
return;
|
|
105
|
+
if (!isPlainObject(value)) {
|
|
106
|
+
throw new StateMigrationHardStopError(collection === 'epics' ? 'invalid_epics_collection' : 'invalid_stories_collection');
|
|
107
|
+
}
|
|
108
|
+
if (Object.keys(value).length > 4096) {
|
|
109
|
+
throw new StateMigrationHardStopError(collection === 'epics' ? 'invalid_epics_collection' : 'invalid_stories_collection');
|
|
110
|
+
}
|
|
111
|
+
for (const [id, record] of Object.entries(value)) {
|
|
112
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(id)) {
|
|
113
|
+
throw new StateMigrationHardStopError(collection === 'epics' ? 'invalid_epic_record' : 'invalid_story_record');
|
|
114
|
+
}
|
|
115
|
+
if (!isPlainObject(record)) {
|
|
116
|
+
throw new StateMigrationHardStopError(collection === 'epics' ? 'invalid_epic_record' : 'invalid_story_record');
|
|
117
|
+
}
|
|
118
|
+
if (record.id !== undefined && record.id !== id) {
|
|
119
|
+
throw new StateMigrationHardStopError('record_identity_conflict', 'record identity conflicts with its map key');
|
|
120
|
+
}
|
|
121
|
+
if (collection === 'epics' && record.stories !== undefined && !Array.isArray(record.stories)) {
|
|
122
|
+
throw new StateMigrationHardStopError('invalid_epic_record', 'epic stories membership is not an array');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function storyIds(value, source) {
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
return [];
|
|
129
|
+
if (!Array.isArray(value))
|
|
130
|
+
throw new StateMigrationHardStopError('legacy_story_shape_invalid', `${source}.stories is not an array`);
|
|
131
|
+
if (value.length > 4096)
|
|
132
|
+
throw new StateMigrationHardStopError('legacy_story_shape_invalid', `${source}.stories is unbounded`);
|
|
133
|
+
const ids = [];
|
|
134
|
+
for (const [index, item] of value.entries()) {
|
|
135
|
+
const id = typeof item === 'string'
|
|
136
|
+
? item
|
|
137
|
+
: isPlainObject(item) && typeof item.id === 'string'
|
|
138
|
+
? item.id
|
|
139
|
+
: null;
|
|
140
|
+
if (!id || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(id)) {
|
|
141
|
+
throw new StateMigrationHardStopError('legacy_story_id_missing', `${source}.stories[${index}] has no stable id`);
|
|
142
|
+
}
|
|
143
|
+
if (ids.includes(id))
|
|
144
|
+
throw new StateMigrationHardStopError('migration_value_conflict', `${source} repeats a story id`);
|
|
145
|
+
ids.push(id);
|
|
146
|
+
}
|
|
147
|
+
return ids;
|
|
148
|
+
}
|
|
149
|
+
function mergeWithoutLoss(target, candidate) {
|
|
150
|
+
const merged = clone(target);
|
|
151
|
+
for (const [key, value] of Object.entries(candidate)) {
|
|
152
|
+
if (!(key in merged)) {
|
|
153
|
+
merged[key] = clone(value);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!equal(merged[key], value)) {
|
|
157
|
+
throw new StateMigrationHardStopError('migration_value_conflict', 'canonical and legacy values are unequal');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return merged;
|
|
161
|
+
}
|
|
162
|
+
function legacyEpicCandidate(entry, epicId, history) {
|
|
163
|
+
const candidate = clone(entry);
|
|
164
|
+
delete candidate.epic_id;
|
|
165
|
+
candidate.id = epicId;
|
|
166
|
+
candidate.stories = storyIds(entry.stories, history ? `planning.history[${epicId}]` : 'planning.current');
|
|
167
|
+
if (candidate.status === undefined)
|
|
168
|
+
candidate.status = history ? 'done' : 'in-progress';
|
|
169
|
+
if (history && candidate.status !== 'done') {
|
|
170
|
+
throw new StateMigrationHardStopError('migration_value_conflict', `planning.history[${epicId}].status must be done`);
|
|
171
|
+
}
|
|
172
|
+
return candidate;
|
|
173
|
+
}
|
|
174
|
+
function mergeLegacyEpic(entry, source, history, epics, stories, operations) {
|
|
175
|
+
const epicId = typeof entry.epic_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(entry.epic_id) ? entry.epic_id : null;
|
|
176
|
+
if (!epicId)
|
|
177
|
+
throw new StateMigrationHardStopError('legacy_epic_id_missing', `${source}.epic_id is required`);
|
|
178
|
+
const candidate = legacyEpicCandidate(entry, epicId, history);
|
|
179
|
+
const existingEpic = epics[epicId];
|
|
180
|
+
if (existingEpic !== undefined && !isPlainObject(existingEpic))
|
|
181
|
+
throw new StateMigrationHardStopError('invalid_epic_record');
|
|
182
|
+
epics[epicId] = existingEpic === undefined
|
|
183
|
+
? candidate
|
|
184
|
+
: mergeWithoutLoss(existingEpic, candidate);
|
|
185
|
+
operations.push({ kind: 'merge-epic', source, target: `epics.${epicId}` });
|
|
186
|
+
if (Array.isArray(entry.stories)) {
|
|
187
|
+
for (const [index, item] of entry.stories.entries()) {
|
|
188
|
+
if (typeof item === 'string')
|
|
189
|
+
continue;
|
|
190
|
+
if (!isPlainObject(item))
|
|
191
|
+
throw new StateMigrationHardStopError('legacy_story_shape_invalid', `${source}.stories[${index}] is invalid`);
|
|
192
|
+
const storyId = typeof item.id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/.test(item.id) ? item.id : null;
|
|
193
|
+
if (!storyId)
|
|
194
|
+
throw new StateMigrationHardStopError('legacy_story_id_missing');
|
|
195
|
+
const candidateStory = clone(item);
|
|
196
|
+
candidateStory.id = storyId;
|
|
197
|
+
candidateStory.epic_id = epicId;
|
|
198
|
+
const existingStory = stories[storyId];
|
|
199
|
+
if (existingStory !== undefined && !isPlainObject(existingStory))
|
|
200
|
+
throw new StateMigrationHardStopError('invalid_story_record');
|
|
201
|
+
stories[storyId] = existingStory === undefined
|
|
202
|
+
? candidateStory
|
|
203
|
+
: mergeWithoutLoss(existingStory, candidateStory);
|
|
204
|
+
operations.push({ kind: 'merge-story', source: `${source}.stories[${index}]`, target: `stories.${storyId}` });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Build a deterministic, no-I/O migration plan. Every destructive decision is
|
|
210
|
+
* preceded by complete structural validation and a lossless canonical merge.
|
|
211
|
+
*/
|
|
212
|
+
export function planStateMigration(state) {
|
|
213
|
+
if (!isPlainObject(state))
|
|
214
|
+
throw new StateMigrationHardStopError('state_not_object');
|
|
215
|
+
assertJsonValue(state);
|
|
216
|
+
assertCompleteState(state);
|
|
217
|
+
if (state.schema_version !== undefined && state.schema_version !== 0 && state.schema_version !== 1) {
|
|
218
|
+
throw new StateMigrationHardStopError('unsupported_schema_version');
|
|
219
|
+
}
|
|
220
|
+
validateCanonicalCollection(state.epics, 'epics');
|
|
221
|
+
validateCanonicalCollection(state.stories, 'stories');
|
|
222
|
+
if (state.planning !== undefined && !isPlainObject(state.planning)) {
|
|
223
|
+
throw new StateMigrationHardStopError('invalid_planning_container');
|
|
224
|
+
}
|
|
225
|
+
const planning = isPlainObject(state.planning) ? state.planning : undefined;
|
|
226
|
+
if (planning?.current !== undefined && !isPlainObject(planning.current)) {
|
|
227
|
+
throw new StateMigrationHardStopError('invalid_planning_current');
|
|
228
|
+
}
|
|
229
|
+
if (planning?.history !== undefined && !Array.isArray(planning.history)) {
|
|
230
|
+
throw new StateMigrationHardStopError('invalid_planning_history');
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(planning?.history) && planning.history.length > 4096) {
|
|
233
|
+
throw new StateMigrationHardStopError('invalid_planning_history');
|
|
234
|
+
}
|
|
235
|
+
const sourceCanonicalHash = stateMigrationDigest(state);
|
|
236
|
+
const hasLegacy = planning?.current !== undefined || planning?.history !== undefined;
|
|
237
|
+
const needsCollections = !isPlainObject(state.epics) || !isPlainObject(state.stories);
|
|
238
|
+
if (state.schema_version === 1 && !hasLegacy && !needsCollections) {
|
|
239
|
+
return { kind: 'unchanged', sourceCanonicalHash, state };
|
|
240
|
+
}
|
|
241
|
+
const target = clone(state);
|
|
242
|
+
const operations = [];
|
|
243
|
+
if (!isPlainObject(target.epics)) {
|
|
244
|
+
target.epics = {};
|
|
245
|
+
operations.push({ kind: 'initialize-collection', target: 'epics' });
|
|
246
|
+
}
|
|
247
|
+
if (!isPlainObject(target.stories)) {
|
|
248
|
+
target.stories = {};
|
|
249
|
+
operations.push({ kind: 'initialize-collection', target: 'stories' });
|
|
250
|
+
}
|
|
251
|
+
const epics = target.epics;
|
|
252
|
+
const stories = target.stories;
|
|
253
|
+
const targetPlanning = isPlainObject(target.planning) ? target.planning : undefined;
|
|
254
|
+
if (targetPlanning && targetPlanning.current !== undefined) {
|
|
255
|
+
if (!isPlainObject(targetPlanning.current))
|
|
256
|
+
throw new StateMigrationHardStopError('invalid_planning_current');
|
|
257
|
+
const currentEpicId = typeof targetPlanning.current.epic_id === 'string' && targetPlanning.current.epic_id.trim() !== '';
|
|
258
|
+
const currentStatus = typeof targetPlanning.current.status === 'string'
|
|
259
|
+
? targetPlanning.current.status.trim().toLowerCase().replace(/[_\s]+/g, '-')
|
|
260
|
+
: '';
|
|
261
|
+
if (!currentEpicId && currentStatus === 'generated') {
|
|
262
|
+
const preservation = target.state_migration_legacy;
|
|
263
|
+
if (preservation !== undefined && !isPlainObject(preservation)) {
|
|
264
|
+
throw new StateMigrationHardStopError('migration_value_conflict', 'state_migration_legacy is not an object');
|
|
265
|
+
}
|
|
266
|
+
const envelope = isPlainObject(preservation) ? preservation : {};
|
|
267
|
+
if (envelope.planning_current !== undefined && !equal(envelope.planning_current, targetPlanning.current)) {
|
|
268
|
+
throw new StateMigrationHardStopError('migration_value_conflict', 'state_migration_legacy.planning_current conflicts');
|
|
269
|
+
}
|
|
270
|
+
envelope.planning_current = clone(targetPlanning.current);
|
|
271
|
+
target.state_migration_legacy = envelope;
|
|
272
|
+
operations.push({ kind: 'preserve-legacy', source: 'planning.current', target: 'state_migration_legacy.planning_current' });
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
mergeLegacyEpic(targetPlanning.current, 'planning.current', false, epics, stories, operations);
|
|
276
|
+
}
|
|
277
|
+
delete targetPlanning.current;
|
|
278
|
+
operations.push({ kind: 'remove-legacy', target: 'planning.current' });
|
|
279
|
+
}
|
|
280
|
+
if (targetPlanning && targetPlanning.history !== undefined) {
|
|
281
|
+
if (!Array.isArray(targetPlanning.history))
|
|
282
|
+
throw new StateMigrationHardStopError('invalid_planning_history');
|
|
283
|
+
for (const [index, entry] of targetPlanning.history.entries()) {
|
|
284
|
+
if (!isPlainObject(entry))
|
|
285
|
+
throw new StateMigrationHardStopError('invalid_planning_history', `planning.history[${index}] is invalid`);
|
|
286
|
+
mergeLegacyEpic(entry, `planning.history[${index}]`, true, epics, stories, operations);
|
|
287
|
+
}
|
|
288
|
+
delete targetPlanning.history;
|
|
289
|
+
operations.push({ kind: 'remove-legacy', target: 'planning.history' });
|
|
290
|
+
}
|
|
291
|
+
if (targetPlanning && Object.keys(targetPlanning).length === 0)
|
|
292
|
+
delete target.planning;
|
|
293
|
+
if (target.schema_version !== 1) {
|
|
294
|
+
target.schema_version = 1;
|
|
295
|
+
operations.push({ kind: 'set-schema-version', target: 'schema_version' });
|
|
296
|
+
}
|
|
297
|
+
const targetCanonicalHash = stateMigrationDigest(target);
|
|
298
|
+
const steps = [...operations].sort((left, right) => {
|
|
299
|
+
const leftKey = `${left.kind}\0${left.source ?? ''}\0${left.target}`;
|
|
300
|
+
const rightKey = `${right.kind}\0${right.source ?? ''}\0${right.target}`;
|
|
301
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
302
|
+
});
|
|
303
|
+
const migratedEpicIds = Array.from(new Set(steps
|
|
304
|
+
.filter((step) => step.kind === 'merge-epic')
|
|
305
|
+
.map((step) => step.target.slice('epics.'.length)))).sort();
|
|
306
|
+
const migratedStoryIds = Array.from(new Set(steps
|
|
307
|
+
.filter((step) => step.kind === 'merge-story')
|
|
308
|
+
.map((step) => step.target.slice('stories.'.length)))).sort();
|
|
309
|
+
const operationId = `smig_${createHash('sha256')
|
|
310
|
+
.update(`${STATE_MIGRATION_PLAN_SCHEMA}\0${sourceCanonicalHash}\0${targetCanonicalHash}`)
|
|
311
|
+
.digest('hex')
|
|
312
|
+
.slice(0, 32)}`;
|
|
313
|
+
const plan = {
|
|
314
|
+
schemaVersion: STATE_MIGRATION_PLAN_SCHEMA,
|
|
315
|
+
operationId,
|
|
316
|
+
sourceSchemaVersion: state.schema_version === 0 || state.schema_version === 1 ? state.schema_version : 'absent',
|
|
317
|
+
targetSchemaVersion: 1,
|
|
318
|
+
sourceCanonicalHash,
|
|
319
|
+
targetCanonicalHash,
|
|
320
|
+
targetState: target,
|
|
321
|
+
steps,
|
|
322
|
+
migratedEpicIds,
|
|
323
|
+
migratedStoryIds,
|
|
324
|
+
preservedUnknownFieldCount: preservedUnknownFieldCount(state),
|
|
325
|
+
reasonCodes: ['validated_source', 'lossless_mapping_proven', 'deterministic_target'],
|
|
326
|
+
};
|
|
327
|
+
assertStateMigrationPlanV1(plan);
|
|
328
|
+
return {
|
|
329
|
+
kind: 'migrate',
|
|
330
|
+
plan,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function assertHash(value, field) {
|
|
334
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
|
|
335
|
+
throw new StateMigrationHardStopError('migration_value_conflict', `${field} is not a SHA-256 digest`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function assertBoundedStringArray(value, field, limit = 4096) {
|
|
339
|
+
if (!Array.isArray(value) || value.length > limit || value.some((entry) => typeof entry !== 'string' || entry.length === 0 || entry.length > 160 || !/^[A-Za-z0-9_.:+-]+$/.test(entry))) {
|
|
340
|
+
throw new StateMigrationHardStopError('migration_value_conflict', `${field} is invalid or unbounded`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
export function assertStateMigrationPlanV1(value) {
|
|
344
|
+
const allowedKeys = new Set([
|
|
345
|
+
'schemaVersion', 'operationId', 'sourceSchemaVersion', 'targetSchemaVersion',
|
|
346
|
+
'sourceCanonicalHash', 'targetCanonicalHash', 'targetState', 'steps',
|
|
347
|
+
'migratedEpicIds', 'migratedStoryIds', 'preservedUnknownFieldCount', 'reasonCodes',
|
|
348
|
+
]);
|
|
349
|
+
if (!isPlainObject(value)
|
|
350
|
+
|| Object.keys(value).some((key) => !allowedKeys.has(key))
|
|
351
|
+
|| value.schemaVersion !== STATE_MIGRATION_PLAN_SCHEMA
|
|
352
|
+
|| typeof value.operationId !== 'string' || !/^smig_[a-f0-9]{32}$/.test(value.operationId)
|
|
353
|
+
|| ![0, 1, 'absent'].includes(value.sourceSchemaVersion)
|
|
354
|
+
|| value.targetSchemaVersion !== 1
|
|
355
|
+
|| !isPlainObject(value.targetState)
|
|
356
|
+
|| typeof value.preservedUnknownFieldCount !== 'number'
|
|
357
|
+
|| !Number.isSafeInteger(value.preservedUnknownFieldCount)
|
|
358
|
+
|| value.preservedUnknownFieldCount < 0 || value.preservedUnknownFieldCount > 1_000_000) {
|
|
359
|
+
throw new StateMigrationHardStopError('migration_value_conflict', 'StateMigrationPlan/1.0 is invalid');
|
|
360
|
+
}
|
|
361
|
+
assertHash(value.sourceCanonicalHash, 'sourceCanonicalHash');
|
|
362
|
+
assertHash(value.targetCanonicalHash, 'targetCanonicalHash');
|
|
363
|
+
assertBoundedStringArray(value.migratedEpicIds, 'migratedEpicIds');
|
|
364
|
+
assertBoundedStringArray(value.migratedStoryIds, 'migratedStoryIds');
|
|
365
|
+
assertBoundedStringArray(value.reasonCodes, 'reasonCodes', 32);
|
|
366
|
+
if (!Array.isArray(value.steps) || value.steps.length > 8192 || value.steps.some((step) => !isPlainObject(step)
|
|
367
|
+
|| Object.keys(step).some((key) => !['kind', 'source', 'target'].includes(key))
|
|
368
|
+
|| !['initialize-collection', 'merge-epic', 'merge-story', 'preserve-legacy', 'remove-legacy', 'set-schema-version'].includes(String(step.kind))
|
|
369
|
+
|| typeof step.target !== 'string' || step.target.length === 0 || step.target.length > 512 || !/^[A-Za-z0-9_.:[\]-]+$/.test(step.target)
|
|
370
|
+
|| (step.source !== undefined && (typeof step.source !== 'string' || step.source.length > 512 || !/^[A-Za-z0-9_.:[\]-]+$/.test(step.source))))) {
|
|
371
|
+
throw new StateMigrationHardStopError('migration_value_conflict', 'steps are invalid or unbounded');
|
|
372
|
+
}
|
|
373
|
+
if (stateMigrationDigest(value.targetState) !== value.targetCanonicalHash) {
|
|
374
|
+
throw new StateMigrationHardStopError('migration_value_conflict', 'targetState hash mismatch');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
export function assertStateMigrationReceiptV1(value) {
|
|
378
|
+
const allowedKeys = new Set([
|
|
379
|
+
'schemaVersion', 'operationId', 'outcome', 'sourceBytesHash', 'targetBytesHash',
|
|
380
|
+
'sourceCanonicalHash', 'targetCanonicalHash', 'backupHash', 'rollbackStatus',
|
|
381
|
+
'migratedEpicCount', 'migratedStoryCount', 'preservedUnknownFieldCount',
|
|
382
|
+
'reasonCodes', 'publicSafe',
|
|
383
|
+
]);
|
|
384
|
+
if (!isPlainObject(value)
|
|
385
|
+
|| Object.keys(value).some((key) => !allowedKeys.has(key))
|
|
386
|
+
|| value.schemaVersion !== STATE_MIGRATION_RECEIPT_SCHEMA
|
|
387
|
+
|| typeof value.operationId !== 'string' || !/^smig_[a-f0-9]{32}$/.test(value.operationId)
|
|
388
|
+
|| !['applied', 'already-applied', 'blocked'].includes(String(value.outcome))
|
|
389
|
+
|| !['not-required', 'restored', 'unverified'].includes(String(value.rollbackStatus))
|
|
390
|
+
|| value.publicSafe !== true
|
|
391
|
+
|| !Number.isSafeInteger(value.migratedEpicCount) || value.migratedEpicCount < 0 || value.migratedEpicCount > 4096
|
|
392
|
+
|| !Number.isSafeInteger(value.migratedStoryCount) || value.migratedStoryCount < 0 || value.migratedStoryCount > 4096
|
|
393
|
+
|| !Number.isSafeInteger(value.preservedUnknownFieldCount) || value.preservedUnknownFieldCount < 0 || value.preservedUnknownFieldCount > 1_000_000
|
|
394
|
+
|| (value.backupHash !== null && typeof value.backupHash !== 'string')) {
|
|
395
|
+
throw new StateMigrationHardStopError('migration_value_conflict', 'StateMigrationReceipt/1.0 is invalid');
|
|
396
|
+
}
|
|
397
|
+
assertHash(value.sourceBytesHash, 'sourceBytesHash');
|
|
398
|
+
assertHash(value.targetBytesHash, 'targetBytesHash');
|
|
399
|
+
assertHash(value.sourceCanonicalHash, 'sourceCanonicalHash');
|
|
400
|
+
assertHash(value.targetCanonicalHash, 'targetCanonicalHash');
|
|
401
|
+
if (value.backupHash !== null)
|
|
402
|
+
assertHash(value.backupHash, 'backupHash');
|
|
403
|
+
assertBoundedStringArray(value.reasonCodes, 'reasonCodes', 32);
|
|
404
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export function canonicalYoloopJson(value) {
|
|
3
|
+
if (value === null || typeof value !== 'object')
|
|
4
|
+
return JSON.stringify(value);
|
|
5
|
+
if (Array.isArray(value))
|
|
6
|
+
return `[${value.map(canonicalYoloopJson).join(',')}]`;
|
|
7
|
+
return `{${Object.entries(value)
|
|
8
|
+
.filter(([, child]) => child !== undefined)
|
|
9
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
10
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonicalYoloopJson(child)}`)
|
|
11
|
+
.join(',')}}`;
|
|
12
|
+
}
|
|
13
|
+
export function yoloopDomainHash(domain, value) {
|
|
14
|
+
return createHash('sha256').update(`${domain}\0${canonicalYoloopJson(value)}`).digest('hex');
|
|
15
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P220.11 -- Finite, tier-independent operational safety budget for yoloop.
|
|
3
|
+
*
|
|
4
|
+
* This contract deliberately does not model entitlement quota or graph
|
|
5
|
+
* retrieval limits. It answers one question only: may an admitted run safely
|
|
6
|
+
* continue at this boundary?
|
|
7
|
+
*/
|
|
8
|
+
export const YOLOOP_OPERATIONAL_BUDGET_DIMENSIONS = [
|
|
9
|
+
'deadline',
|
|
10
|
+
'wall',
|
|
11
|
+
'turns',
|
|
12
|
+
'tokens',
|
|
13
|
+
'cost',
|
|
14
|
+
'toolCalls',
|
|
15
|
+
'attempts',
|
|
16
|
+
'feedback',
|
|
17
|
+
'depth',
|
|
18
|
+
'concurrency',
|
|
19
|
+
'risk',
|
|
20
|
+
];
|
|
21
|
+
export const DEFAULT_YOLOOP_OPERATIONAL_BUDGET_LIMITS = Object.freeze({
|
|
22
|
+
deadline: 6 * 60 * 60 * 1_000,
|
|
23
|
+
wall: 6 * 60 * 60 * 1_000,
|
|
24
|
+
turns: 256,
|
|
25
|
+
tokens: 5_000_000,
|
|
26
|
+
cost: 1_000_000,
|
|
27
|
+
toolCalls: 512,
|
|
28
|
+
attempts: 3,
|
|
29
|
+
feedback: 32,
|
|
30
|
+
depth: 1,
|
|
31
|
+
concurrency: 8,
|
|
32
|
+
risk: 100,
|
|
33
|
+
});
|
|
34
|
+
export const MAX_YOLOOP_OPERATIONAL_BUDGET_LIMITS = Object.freeze({
|
|
35
|
+
deadline: 24 * 60 * 60 * 1_000,
|
|
36
|
+
wall: 24 * 60 * 60 * 1_000,
|
|
37
|
+
turns: 1_024,
|
|
38
|
+
tokens: 20_000_000,
|
|
39
|
+
cost: 1_000_000,
|
|
40
|
+
toolCalls: 2_048,
|
|
41
|
+
attempts: 10,
|
|
42
|
+
feedback: 128,
|
|
43
|
+
depth: 1,
|
|
44
|
+
concurrency: 32,
|
|
45
|
+
risk: 100,
|
|
46
|
+
});
|
|
47
|
+
function isPositiveSafeInteger(value) {
|
|
48
|
+
return Number.isSafeInteger(value) && Number(value) > 0;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Normalize operator input to finite limits. Invalid, absent, zero, negative,
|
|
52
|
+
* non-integer, non-finite and over-ceiling values fall back dimension by
|
|
53
|
+
* dimension; there is no unlimited sentinel.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeYoloopOperationalBudget(rawLimits, startedAtMs, defaults = DEFAULT_YOLOOP_OPERATIONAL_BUDGET_LIMITS, ceilings = MAX_YOLOOP_OPERATIONAL_BUDGET_LIMITS) {
|
|
56
|
+
if (!Number.isSafeInteger(startedAtMs) || startedAtMs < 0) {
|
|
57
|
+
throw new Error('YOLOOP_OPERATIONAL_BUDGET_INVALID');
|
|
58
|
+
}
|
|
59
|
+
const fallbackDimensions = [];
|
|
60
|
+
const limits = {};
|
|
61
|
+
for (const dimension of YOLOOP_OPERATIONAL_BUDGET_DIMENSIONS) {
|
|
62
|
+
const candidate = rawLimits?.[dimension];
|
|
63
|
+
const ceiling = ceilings[dimension];
|
|
64
|
+
const fallback = defaults[dimension];
|
|
65
|
+
if (!isPositiveSafeInteger(ceiling) || !isPositiveSafeInteger(fallback) || fallback > ceiling) {
|
|
66
|
+
throw new Error('YOLOOP_OPERATIONAL_BUDGET_INVALID');
|
|
67
|
+
}
|
|
68
|
+
if (!isPositiveSafeInteger(candidate) || candidate > ceiling) {
|
|
69
|
+
limits[dimension] = fallback;
|
|
70
|
+
fallbackDimensions.push(dimension);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
limits[dimension] = candidate;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
budget: {
|
|
78
|
+
schemaVersion: 'YoloopOperationalBudget/1.0',
|
|
79
|
+
startedAtMs,
|
|
80
|
+
limits: Object.freeze(limits),
|
|
81
|
+
},
|
|
82
|
+
fallbackDimensions: Object.freeze(fallbackDimensions),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Strict validation for state received over the persistence wire. */
|
|
86
|
+
export function validateYoloopOperationalBudget(budget) {
|
|
87
|
+
if (!budget || typeof budget !== 'object')
|
|
88
|
+
return false;
|
|
89
|
+
const candidate = budget;
|
|
90
|
+
if (candidate.schemaVersion !== 'YoloopOperationalBudget/1.0'
|
|
91
|
+
|| !Number.isSafeInteger(candidate.startedAtMs)
|
|
92
|
+
|| Number(candidate.startedAtMs) < 0
|
|
93
|
+
|| !candidate.limits)
|
|
94
|
+
return false;
|
|
95
|
+
return YOLOOP_OPERATIONAL_BUDGET_DIMENSIONS.every((dimension) => {
|
|
96
|
+
const value = candidate.limits?.[dimension];
|
|
97
|
+
return isPositiveSafeInteger(value)
|
|
98
|
+
&& value <= MAX_YOLOOP_OPERATIONAL_BUDGET_LIMITS[dimension];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Evaluate in canonical O(11) order. Reaching a limit is exhaustion, so a
|
|
103
|
+
* boundary can never issue one more effect after consuming its full budget.
|
|
104
|
+
*/
|
|
105
|
+
export function evaluateYoloopOperationalBudget(input) {
|
|
106
|
+
if (!validateYoloopOperationalBudget(input.budget) || !Number.isSafeInteger(input.nowMs)) {
|
|
107
|
+
return { kind: 'invalid', reasonCode: 'YOLOOP_OPERATIONAL_BUDGET_INVALID', dimension: null };
|
|
108
|
+
}
|
|
109
|
+
const usage = {};
|
|
110
|
+
for (const dimension of YOLOOP_OPERATIONAL_BUDGET_DIMENSIONS) {
|
|
111
|
+
const rawUsed = input.usage[dimension];
|
|
112
|
+
if (!Number.isSafeInteger(rawUsed) || Number(rawUsed) < 0) {
|
|
113
|
+
return {
|
|
114
|
+
kind: 'invalid',
|
|
115
|
+
reasonCode: 'YOLOOP_OPERATIONAL_BUDGET_INVALID',
|
|
116
|
+
dimension,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
usage[dimension] = Number(rawUsed);
|
|
120
|
+
}
|
|
121
|
+
const elapsed = input.nowMs - input.budget.startedAtMs;
|
|
122
|
+
if (!Number.isSafeInteger(elapsed) || elapsed < 0) {
|
|
123
|
+
return {
|
|
124
|
+
kind: 'invalid',
|
|
125
|
+
reasonCode: 'YOLOOP_OPERATIONAL_BUDGET_INVALID',
|
|
126
|
+
dimension: 'deadline',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
usage.deadline = Math.max(usage.deadline, elapsed);
|
|
130
|
+
for (const dimension of YOLOOP_OPERATIONAL_BUDGET_DIMENSIONS) {
|
|
131
|
+
const used = usage[dimension];
|
|
132
|
+
const limit = input.budget.limits[dimension];
|
|
133
|
+
if (used >= limit) {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'exhausted',
|
|
136
|
+
status: 'exhausted',
|
|
137
|
+
dimension,
|
|
138
|
+
limit,
|
|
139
|
+
used,
|
|
140
|
+
remaining: 0,
|
|
141
|
+
reasonCode: dimension === 'deadline'
|
|
142
|
+
? 'YOLOOP_SESSION_TIMEOUT'
|
|
143
|
+
: 'YOLOOP_OPERATIONAL_BUDGET_EXHAUSTED',
|
|
144
|
+
usage: Object.freeze(usage),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { kind: 'within-budget', usage: Object.freeze(usage) };
|
|
149
|
+
}
|
|
150
|
+
export function createZeroYoloopOperationalUsage() {
|
|
151
|
+
return Object.freeze({
|
|
152
|
+
deadline: 0,
|
|
153
|
+
wall: 0,
|
|
154
|
+
turns: 0,
|
|
155
|
+
tokens: 0,
|
|
156
|
+
cost: 0,
|
|
157
|
+
toolCalls: 0,
|
|
158
|
+
attempts: 0,
|
|
159
|
+
feedback: 0,
|
|
160
|
+
depth: 0,
|
|
161
|
+
concurrency: 0,
|
|
162
|
+
risk: 0,
|
|
163
|
+
});
|
|
164
|
+
}
|