@deksden-com/dd-flow-cli 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.
Files changed (35) hide show
  1. package/README.md +274 -0
  2. package/dist/cli/help.js +308 -0
  3. package/dist/cli/run-cli.js +945 -0
  4. package/dist/cli.js +4 -0
  5. package/dist/domain/contracts.js +57 -0
  6. package/dist/domain/entity-ids.js +47 -0
  7. package/dist/domain/flow-contract.js +233 -0
  8. package/dist/domain/validation.js +91 -0
  9. package/dist/protocol/local-files.js +141 -0
  10. package/dist/runtime/context.js +11 -0
  11. package/dist/schemas/code-stage-report.schema.json +181 -0
  12. package/dist/schemas/flow-run-index.schema.json +129 -0
  13. package/dist/schemas/mb-upgrade-review-data.schema.json +813 -0
  14. package/dist/schemas/memorybank-permissions-preflight.schema.json +154 -0
  15. package/dist/schemas/merge-stage-report.schema.json +135 -0
  16. package/dist/services/audit.js +19 -0
  17. package/dist/services/cleanup.js +310 -0
  18. package/dist/services/config.js +143 -0
  19. package/dist/services/dashboard.js +436 -0
  20. package/dist/services/hooks.js +929 -0
  21. package/dist/services/lanes.js +327 -0
  22. package/dist/services/memory-permissions.js +344 -0
  23. package/dist/services/merge-queue.js +333 -0
  24. package/dist/services/plans.js +149 -0
  25. package/dist/services/projects.js +286 -0
  26. package/dist/services/protocols.js +606 -0
  27. package/dist/services/runs.js +359 -0
  28. package/dist/services/schema-validation.js +185 -0
  29. package/dist/services/sessions.js +365 -0
  30. package/dist/services/worktrees.js +204 -0
  31. package/dist/shared/errors.js +14 -0
  32. package/dist/shared/json.js +17 -0
  33. package/dist/storage/database.js +325 -0
  34. package/dist/storage/paths.js +56 -0
  35. package/package.json +44 -0
@@ -0,0 +1,333 @@
1
+ import { loadProjectFlowContract } from "../domain/flow-contract.js";
2
+ import { AppError } from "../shared/errors.js";
3
+ import { appendAudit } from "./audit.js";
4
+ import { requireProjectByRoot } from "./projects.js";
5
+ import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
6
+ import { resolveProjectRoot } from "../storage/paths.js";
7
+ import { acquireLaneLock, ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, requireLaneLockOwner } from "./lanes.js";
8
+ import { stoppedMergeWorkerState } from "./sessions.js";
9
+ const mergeLockTtlSeconds = 300;
10
+ export function getMergeQueueStatus(context, input) {
11
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
12
+ return {
13
+ ok: true,
14
+ queue: queueForProject(context, project.id)
15
+ };
16
+ }
17
+ export function claimNextMergeJob(context, input) {
18
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
19
+ requireLaneLockOwner(context, {
20
+ projectRoot: project.root,
21
+ lane: "merge",
22
+ workerId: input.workerId,
23
+ workspacePath: input.workspacePath
24
+ });
25
+ const now = context.now();
26
+ let claimedProtocolId = null;
27
+ context.db.exec("BEGIN IMMEDIATE");
28
+ try {
29
+ const job = context.db.get(`SELECT * FROM merge_queue
30
+ WHERE project_id = ? AND status IN ('ready', 'requeued')
31
+ ORDER BY created_at ASC, id ASC
32
+ LIMIT 1`, [project.id]);
33
+ if (!job) {
34
+ context.db.exec("COMMIT");
35
+ return { ok: true, job: null };
36
+ }
37
+ const update = context.db.run(`UPDATE merge_queue
38
+ SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
39
+ WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
40
+ if (update.changes !== 1) {
41
+ context.db.exec("COMMIT");
42
+ return { ok: true, job: null };
43
+ }
44
+ appendAudit(context, {
45
+ protocolId: job.protocol_id,
46
+ projectId: project.id,
47
+ eventType: "merge_queue.claimed",
48
+ payload: { protocol_id: job.protocol_id, worker_id: input.workerId }
49
+ });
50
+ claimedProtocolId = job.protocol_id;
51
+ context.db.exec("COMMIT");
52
+ }
53
+ catch (error) {
54
+ context.db.exec("ROLLBACK");
55
+ throw error;
56
+ }
57
+ return { ok: true, job: claimedProtocolId ? queueJobByProtocol(context, claimedProtocolId) : null };
58
+ }
59
+ export async function waitNextMergeJob(context, input) {
60
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
61
+ const timeoutSeconds = input.timeoutSeconds ?? 0;
62
+ const pollIntervalSeconds = input.pollIntervalSeconds ?? 10;
63
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) {
64
+ throw new AppError("validation", "--timeout must be zero or a positive number", 2);
65
+ }
66
+ if (!Number.isFinite(pollIntervalSeconds) || pollIntervalSeconds <= 0) {
67
+ throw new AppError("validation", "--poll-interval must be a positive number", 2);
68
+ }
69
+ if (input.acquireLock && pollIntervalSeconds >= mergeLockTtlSeconds) {
70
+ throw new AppError("validation", "--poll-interval must be lower than the merge lock TTL when --acquire-lock true", 2, {
71
+ lock_ttl_seconds: mergeLockTtlSeconds
72
+ });
73
+ }
74
+ const initialStopState = stoppedMergeWorkerState(context, project.id, input.workerId);
75
+ if (initialStopState.stopped) {
76
+ return { ok: true, job: null, waited: false, timed_out: false, stopped: true, reason: initialStopState.reason };
77
+ }
78
+ let releaseLockOnTimeout = false;
79
+ if (input.acquireLock) {
80
+ ensureLaneWorkspace(context, { projectRoot: project.root, lane: "merge", workspacePath: input.workspacePath });
81
+ const lockResult = acquireLaneLock(context, {
82
+ projectRoot: project.root,
83
+ lane: "merge",
84
+ workerId: input.workerId,
85
+ workspacePath: input.workspacePath,
86
+ ttlSeconds: mergeLockTtlSeconds,
87
+ reason: "merge queue wait-next"
88
+ });
89
+ releaseLockOnTimeout = !lockResult.reused;
90
+ }
91
+ else {
92
+ requireLaneLockOwner(context, {
93
+ projectRoot: project.root,
94
+ lane: "merge",
95
+ workerId: input.workerId,
96
+ workspacePath: input.workspacePath
97
+ });
98
+ }
99
+ const start = Date.now();
100
+ while (true) {
101
+ const stopState = stoppedMergeWorkerState(context, project.id, input.workerId);
102
+ if (stopState.stopped) {
103
+ if (input.acquireLock) {
104
+ releaseLaneLockIfOwned(context, {
105
+ projectRoot: project.root,
106
+ lane: "merge",
107
+ workerId: input.workerId,
108
+ workspacePath: input.workspacePath,
109
+ reason: stopState.reason ?? "merge worker stopped"
110
+ });
111
+ }
112
+ appendAudit(context, {
113
+ projectId: project.id,
114
+ eventType: "merge_queue.wait_stopped",
115
+ ...(stopState.reason ? { reason: stopState.reason } : {}),
116
+ payload: { project_id: project.id, worker_id: input.workerId }
117
+ });
118
+ return { ok: true, job: null, waited: true, timed_out: false, stopped: true, reason: stopState.reason };
119
+ }
120
+ if (input.acquireLock) {
121
+ heartbeatLaneLock(context, {
122
+ projectRoot: project.root,
123
+ lane: "merge",
124
+ workerId: input.workerId,
125
+ workspacePath: input.workspacePath,
126
+ ttlSeconds: mergeLockTtlSeconds
127
+ });
128
+ }
129
+ const claimed = claimNextMergeJob(context, {
130
+ projectRoot: project.root,
131
+ workerId: input.workerId,
132
+ workspacePath: input.workspacePath
133
+ });
134
+ if (claimed.job) {
135
+ return { ...claimed, waited: true, timed_out: false };
136
+ }
137
+ if (timeoutSeconds > 0 && Date.now() - start >= timeoutSeconds * 1000) {
138
+ appendAudit(context, {
139
+ projectId: project.id,
140
+ eventType: "merge_queue.wait_timeout",
141
+ payload: { project_id: project.id, worker_id: input.workerId, timeout_seconds: timeoutSeconds }
142
+ });
143
+ if (input.acquireLock && releaseLockOnTimeout) {
144
+ releaseLaneLock(context, {
145
+ projectRoot: project.root,
146
+ lane: "merge",
147
+ workerId: input.workerId,
148
+ workspacePath: input.workspacePath,
149
+ reason: "merge queue wait-next timeout"
150
+ });
151
+ }
152
+ return { ok: true, job: null, waited: true, timed_out: true };
153
+ }
154
+ await delay(pollIntervalSeconds * 1000);
155
+ }
156
+ }
157
+ export function completeMergeJob(context, input) {
158
+ const protocol = requireProtocol(context, input.protocolId);
159
+ const job = requireClaimedJob(context, input.protocolId, input.workerId);
160
+ requireLaneLockOwner(context, {
161
+ projectRoot: protocol.project_root,
162
+ lane: "merge",
163
+ workerId: input.workerId,
164
+ workspacePath: input.workspacePath
165
+ });
166
+ const now = context.now();
167
+ context.db.run(`UPDATE merge_queue
168
+ SET status = 'merged', last_reason = ?, completed_at = ?, updated_at = ?
169
+ WHERE id = ?`, [input.summary, now, now, job.id]);
170
+ const state = readProtocolRuntimeState(context, protocol).state;
171
+ const flowContract = loadProjectFlowContract(protocol.project_root);
172
+ const targetStage = flowContract.merge_queue.complete.target_stage;
173
+ const targetStatus = flowContract.stages[targetStage]?.terminal ? targetStage : "running";
174
+ persistProtocolState(context, protocol, {
175
+ ...state,
176
+ stage: targetStage,
177
+ status: targetStatus,
178
+ next_action: flowContract.merge_queue.complete.next_action,
179
+ updated_at: now
180
+ });
181
+ appendAudit(context, {
182
+ protocolId: protocol.id,
183
+ projectId: protocol.project_id,
184
+ eventType: "merge_queue.completed",
185
+ payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary, flow_contract_id: flowContract.id }
186
+ });
187
+ return { ok: true, job: queueJobByProtocol(context, protocol.id), next_stage: targetStage };
188
+ }
189
+ export function noteMergeJob(context, input) {
190
+ const protocol = requireProtocol(context, input.protocolId);
191
+ const job = queueJobByProtocol(context, protocol.id);
192
+ if (!job) {
193
+ throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
194
+ }
195
+ if (job.status !== "merged") {
196
+ throw new AppError("merge_job_not_merged", "Merge queue note requires a merged job", 1, {
197
+ protocol_id: protocol.id,
198
+ status: job.status
199
+ });
200
+ }
201
+ if (job.claimed_by_session_id !== input.workerId) {
202
+ throw new AppError("merge_job_claimed_by_other_session", "Merge job is owned by another worker", 1, {
203
+ protocol_id: protocol.id,
204
+ claimed_by_session_id: job.claimed_by_session_id
205
+ });
206
+ }
207
+ const now = context.now();
208
+ context.db.run(`UPDATE merge_queue
209
+ SET last_reason = ?, updated_at = ?
210
+ WHERE id = ?`, [input.summary, now, job.id]);
211
+ appendAudit(context, {
212
+ protocolId: protocol.id,
213
+ projectId: protocol.project_id,
214
+ eventType: "merge_queue.note",
215
+ payload: { protocol_id: protocol.id, worker_id: input.workerId, summary: input.summary }
216
+ });
217
+ return { ok: true, job: queueJobByProtocol(context, protocol.id) };
218
+ }
219
+ export function failMergeJob(context, input) {
220
+ const protocol = requireProtocol(context, input.protocolId);
221
+ const job = requireClaimedJob(context, input.protocolId, input.workerId);
222
+ requireLaneLockOwner(context, {
223
+ projectRoot: protocol.project_root,
224
+ lane: "merge",
225
+ workerId: input.workerId,
226
+ workspacePath: input.workspacePath
227
+ });
228
+ const now = context.now();
229
+ const status = input.requeue ? "requeued" : "failed";
230
+ context.db.run(`UPDATE merge_queue
231
+ SET status = ?, attempts_count = attempts_count + 1, last_reason = ?, updated_at = ?
232
+ WHERE id = ?`, [status, input.reason, now, job.id]);
233
+ appendAudit(context, {
234
+ protocolId: protocol.id,
235
+ projectId: protocol.project_id,
236
+ eventType: "merge_queue.failed",
237
+ reason: input.reason,
238
+ payload: { protocol_id: protocol.id, worker_id: input.workerId, requeue: input.requeue }
239
+ });
240
+ return { ok: true, job: queueJobByProtocol(context, protocol.id) };
241
+ }
242
+ export function cancelMergeQueueJob(context, input) {
243
+ const protocol = requireProtocol(context, input.protocolId);
244
+ const reason = input.reason.trim();
245
+ if (!reason) {
246
+ throw new AppError("validation", "merge-queue cancel requires --reason", 2);
247
+ }
248
+ const now = context.now();
249
+ context.db.exec("BEGIN IMMEDIATE");
250
+ try {
251
+ const job = queueJobByProtocol(context, protocol.id);
252
+ if (!job) {
253
+ throw new AppError("not_found", "Merge queue job is not found", 1, { protocol_id: protocol.id });
254
+ }
255
+ if (job.status === "cancelled") {
256
+ context.db.exec("COMMIT");
257
+ return { ok: true, job, cancelled: false, reason: "already_cancelled" };
258
+ }
259
+ if (job.status === "claimed" && !input.force) {
260
+ if (!input.workerId) {
261
+ throw new AppError("merge_job_claimed", "Claimed jobs require --worker-id or --force", 1, { protocol_id: protocol.id });
262
+ }
263
+ if (job.claimed_by_session_id !== input.workerId) {
264
+ throw new AppError("merge_job_claimed_by_other_session", "Merge job is claimed by another worker", 1, {
265
+ protocol_id: protocol.id,
266
+ claimed_by_session_id: job.claimed_by_session_id
267
+ });
268
+ }
269
+ requireLaneLockOwner(context, {
270
+ projectRoot: protocol.project_root,
271
+ lane: "merge",
272
+ workerId: input.workerId,
273
+ workspacePath: input.workspacePath
274
+ });
275
+ }
276
+ if (["merged", "failed"].includes(job.status) && !input.force) {
277
+ throw new AppError("merge_job_terminal", `Merge job is ${job.status}; cancellation requires --force --reason`, 1, {
278
+ protocol_id: protocol.id,
279
+ status: job.status
280
+ });
281
+ }
282
+ context.db.run(`UPDATE merge_queue
283
+ SET status = 'cancelled', last_reason = ?, updated_at = ?
284
+ WHERE id = ?`, [reason, now, job.id]);
285
+ appendAudit(context, {
286
+ protocolId: protocol.id,
287
+ projectId: protocol.project_id,
288
+ eventType: "merge_queue.cancelled",
289
+ reason,
290
+ forced: input.force,
291
+ payload: { protocol_id: protocol.id, from: job.status, worker_id: input.workerId ?? null }
292
+ });
293
+ context.db.exec("COMMIT");
294
+ }
295
+ catch (error) {
296
+ context.db.exec("ROLLBACK");
297
+ throw error;
298
+ }
299
+ return { ok: true, job: queueJobByProtocol(context, protocol.id), cancelled: true };
300
+ }
301
+ export function queueForProject(context, projectId) {
302
+ return context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ORDER BY created_at ASC, id ASC`, [projectId]);
303
+ }
304
+ function queueJobByProtocol(context, protocolId) {
305
+ return context.db.get("SELECT * FROM merge_queue WHERE protocol_id = ?", [protocolId]);
306
+ }
307
+ function requireClaimedJob(context, protocolId, sessionId) {
308
+ const job = queueJobByProtocol(context, protocolId);
309
+ if (!job || job.status !== "claimed") {
310
+ throw new AppError("merge_job_not_claimed", "Merge job is not claimed", 1, { protocol_id: protocolId });
311
+ }
312
+ if (job.claimed_by_session_id !== sessionId) {
313
+ throw new AppError("merge_job_claimed_by_other_session", "Merge job is claimed by another session", 1, {
314
+ protocol_id: protocolId,
315
+ claimed_by_session_id: job.claimed_by_session_id
316
+ });
317
+ }
318
+ return job;
319
+ }
320
+ function releaseLaneLockIfOwned(context, input) {
321
+ try {
322
+ releaseLaneLock(context, input);
323
+ }
324
+ catch (error) {
325
+ if (error instanceof AppError && ["lane_lock_not_active", "lane_lock_owned_by_other_worker"].includes(error.code)) {
326
+ return;
327
+ }
328
+ throw error;
329
+ }
330
+ }
331
+ async function delay(milliseconds) {
332
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
333
+ }
@@ -0,0 +1,149 @@
1
+ import fs from "node:fs";
2
+ import { schemaVersion } from "../domain/contracts.js";
3
+ import { validatePlan } from "../domain/validation.js";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { parseJsonObject } from "../shared/json.js";
6
+ import { ensureReadableFile } from "../storage/database.js";
7
+ import { appendAudit } from "./audit.js";
8
+ import { persistProtocolState, readProtocolRuntimeState, requireProtocol } from "./protocols.js";
9
+ import { writePlanFile } from "../protocol/local-files.js";
10
+ export function setProtocolPlan(context, input) {
11
+ const protocol = requireProtocol(context, input.protocolId);
12
+ ensureReadableFile(input.file);
13
+ const plan = validatePlan(parseJsonObject(fs.readFileSync(input.file, "utf8"), input.file), protocol.id);
14
+ writePlanFile(protocol.plan_path, plan);
15
+ context.db.run(`INSERT INTO plans (protocol_id, plan_json, updated_at)
16
+ VALUES (?, ?, ?)
17
+ ON CONFLICT(protocol_id) DO UPDATE SET plan_json = excluded.plan_json, updated_at = excluded.updated_at`, [protocol.id, JSON.stringify(plan), context.now()]);
18
+ updateStatePlanSummary(context, protocol, plan);
19
+ appendAudit(context, {
20
+ protocolId: protocol.id,
21
+ projectId: protocol.project_id,
22
+ eventType: "plan.set",
23
+ payload: { protocol_id: protocol.id, plan_id: plan.plan_id, total: plan.items.length }
24
+ });
25
+ return { ok: true, protocol_id: protocol.id, plan: summarizePlan(plan) };
26
+ }
27
+ export function getPlanStatus(context, input) {
28
+ const protocol = requireProtocol(context, input.protocolId);
29
+ const plan = requireStoredPlan(context, protocol.id);
30
+ return {
31
+ ok: true,
32
+ protocol_id: protocol.id,
33
+ plan: summarizePlan(plan),
34
+ blocked_items: plan.items
35
+ .filter((item) => item.status === "blocked")
36
+ .map((item) => ({
37
+ id: item.id,
38
+ title: item.title,
39
+ reason: item.block_reason ?? "",
40
+ user_required: item.user_required ?? false
41
+ })),
42
+ items: plan.items
43
+ };
44
+ }
45
+ export function startPlanItem(context, input) {
46
+ return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
47
+ assertDependenciesClosed(plan, item);
48
+ return {
49
+ ...item,
50
+ status: "in_progress",
51
+ summary: item.summary || "Started."
52
+ };
53
+ });
54
+ }
55
+ export function completePlanItem(context, input) {
56
+ return updatePlanItem(context, input.protocolId, input.itemId, (item, plan) => {
57
+ assertDependenciesClosed(plan, item);
58
+ return {
59
+ ...item,
60
+ status: "done",
61
+ summary: input.summary,
62
+ evidence: [...new Set([...item.evidence, ...input.evidence])]
63
+ };
64
+ });
65
+ }
66
+ export function blockPlanItem(context, input) {
67
+ return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
68
+ ...item,
69
+ status: "blocked",
70
+ summary: input.reason,
71
+ block_reason: input.reason,
72
+ user_required: input.userRequired
73
+ }));
74
+ }
75
+ export function skipPlanItem(context, input) {
76
+ return updatePlanItem(context, input.protocolId, input.itemId, (item) => ({
77
+ ...item,
78
+ status: "skipped",
79
+ summary: input.reason
80
+ }));
81
+ }
82
+ function updatePlanItem(context, protocolId, itemId, transform) {
83
+ const protocol = requireProtocol(context, protocolId);
84
+ const plan = requireStoredPlan(context, protocol.id);
85
+ const item = plan.items.find((candidate) => candidate.id === itemId);
86
+ if (!item) {
87
+ throw new AppError("not_found", `Plan item is not found: ${itemId}`, 1);
88
+ }
89
+ const nextPlan = {
90
+ ...plan,
91
+ items: plan.items.map((candidate) => (candidate.id === itemId ? transform(candidate, plan) : candidate))
92
+ };
93
+ const validated = validatePlan(nextPlan, protocol.id);
94
+ writePlanFile(protocol.plan_path, validated);
95
+ context.db.run("UPDATE plans SET plan_json = ?, updated_at = ? WHERE protocol_id = ?", [
96
+ JSON.stringify(validated),
97
+ context.now(),
98
+ protocol.id
99
+ ]);
100
+ updateStatePlanSummary(context, protocol, validated);
101
+ appendAudit(context, {
102
+ protocolId: protocol.id,
103
+ projectId: protocol.project_id,
104
+ eventType: "plan.item.updated",
105
+ payload: {
106
+ protocol_id: protocol.id,
107
+ item_id: itemId,
108
+ from: item.status,
109
+ to: validated.items.find((candidate) => candidate.id === itemId)?.status
110
+ }
111
+ });
112
+ return { ok: true, protocol_id: protocol.id, item: validated.items.find((candidate) => candidate.id === itemId) };
113
+ }
114
+ function requireStoredPlan(context, protocolId) {
115
+ const row = context.db.get("SELECT plan_json FROM plans WHERE protocol_id = ?", [protocolId]);
116
+ if (!row) {
117
+ throw new AppError("not_found", `Plan is not attached to protocol: ${protocolId}`, 1);
118
+ }
119
+ return validatePlan(parseJsonObject(row.plan_json, `stored plan for ${protocolId}`), protocolId);
120
+ }
121
+ function assertDependenciesClosed(plan, item) {
122
+ const openDependencies = item.depends_on
123
+ .map((dependencyId) => plan.items.find((candidate) => candidate.id === dependencyId))
124
+ .filter((dependency) => Boolean(dependency))
125
+ .filter((dependency) => !["done", "skipped"].includes(dependency.status));
126
+ if (openDependencies.length > 0) {
127
+ throw new AppError("plan_dependency_open", `Plan item ${item.id} has open dependencies`, 1, {
128
+ item_id: item.id,
129
+ dependencies: openDependencies.map((dependency) => ({ id: dependency.id, status: dependency.status }))
130
+ });
131
+ }
132
+ }
133
+ function updateStatePlanSummary(context, protocol, plan) {
134
+ const state = readProtocolRuntimeState(context, protocol).state;
135
+ persistProtocolState(context, protocol, {
136
+ ...state,
137
+ schema_version: state.schema_version || schemaVersion,
138
+ plan: summarizePlan(plan),
139
+ updated_at: context.now()
140
+ });
141
+ }
142
+ function summarizePlan(plan) {
143
+ return {
144
+ plan_id: plan.plan_id,
145
+ total: plan.items.length,
146
+ done: plan.items.filter((item) => item.status === "done").length,
147
+ blocked: plan.items.filter((item) => item.status === "blocked").length
148
+ };
149
+ }