@3minapi/cli 0.0.0-stage → 0.22.1
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 +21 -0
- package/README.md +88 -2
- package/dist/bin.js +54 -0
- package/dist/cli.js +241 -0
- package/dist/commands/auth.js +573 -0
- package/dist/commands/common.js +48 -0
- package/dist/commands/dashboard.js +298 -0
- package/dist/commands/delete.js +167 -0
- package/dist/commands/doctor.js +326 -0
- package/dist/commands/guide.js +84 -0
- package/dist/commands/index.js +28 -0
- package/dist/commands/init.js +248 -0
- package/dist/commands/logs.js +154 -0
- package/dist/commands/new.js +109 -0
- package/dist/commands/paths.js +64 -0
- package/dist/commands/reload.js +32 -0
- package/dist/commands/results.js +445 -0
- package/dist/commands/start.js +443 -0
- package/dist/commands/stop.js +68 -0
- package/dist/commands/tasks.js +93 -0
- package/dist/commands/worker.js +178 -0
- package/dist/core/api.js +132 -0
- package/dist/core/config.js +188 -0
- package/dist/core/control-db.js +462 -0
- package/dist/core/credentials.js +75 -0
- package/dist/core/domain/constants.js +121 -0
- package/dist/core/domain/contract.js +102 -0
- package/dist/core/domain/errors.js +57 -0
- package/dist/core/domain/index.js +3 -0
- package/dist/core/domain/types.js +1 -0
- package/dist/core/error-report.js +103 -0
- package/dist/core/host/detach.js +49 -0
- package/dist/core/host/live-connection.js +270 -0
- package/dist/core/host/live-receiver.js +264 -0
- package/dist/core/host/module.js +1 -0
- package/dist/core/host/poller.js +113 -0
- package/dist/core/host/pull-step.js +54 -0
- package/dist/core/host/receiver.js +1 -0
- package/dist/core/host/reload.js +34 -0
- package/dist/core/host/runner-lock.js +245 -0
- package/dist/core/host/session.js +84 -0
- package/dist/core/host/usage.js +20 -0
- package/dist/core/log-read.js +59 -0
- package/dist/core/logger.js +206 -0
- package/dist/core/names.js +27 -0
- package/dist/core/network.js +10 -0
- package/dist/core/open-browser.js +26 -0
- package/dist/core/output.js +68 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/project.js +85 -0
- package/dist/core/prompt.js +140 -0
- package/dist/core/redact.js +45 -0
- package/dist/core/secure-fs.js +50 -0
- package/dist/core/text-file.js +12 -0
- package/dist/core/text.js +27 -0
- package/dist/core/time.js +84 -0
- package/dist/core/update-notice.js +142 -0
- package/dist/core/version.js +16 -0
- package/dist/core/warnings.js +10 -0
- package/dist/help.js +144 -0
- package/dist/modules/dashboard/assets.js +411 -0
- package/dist/modules/dashboard/data.js +565 -0
- package/dist/modules/dashboard/guard.js +75 -0
- package/dist/modules/dashboard/lock.js +93 -0
- package/dist/modules/dashboard/server.js +295 -0
- package/dist/modules/dashboard/views/html.js +114 -0
- package/dist/modules/dashboard/views/layout.js +59 -0
- package/dist/modules/dashboard/views/logs.js +122 -0
- package/dist/modules/dashboard/views/overview.js +316 -0
- package/dist/modules/dashboard/views/runs.js +486 -0
- package/dist/modules/dashboard/views/workers.js +157 -0
- package/dist/modules/workers/builtin/host-identity.js +59 -0
- package/dist/modules/workers/builtin/results-worker.js +37 -0
- package/dist/modules/workers/local-server.js +244 -0
- package/dist/modules/workers/module.js +98 -0
- package/dist/modules/workers/reload-result.js +16 -0
- package/dist/modules/workers/repeat-scheduler.js +165 -0
- package/dist/modules/workers/results/missing-paths.js +66 -0
- package/dist/modules/workers/results/query.js +175 -0
- package/dist/modules/workers/results/reader-input.js +134 -0
- package/dist/modules/workers/results/reader.js +430 -0
- package/dist/modules/workers/results/results-child.js +64 -0
- package/dist/modules/workers/results/shape.js +212 -0
- package/dist/modules/workers/results/store.js +336 -0
- package/dist/modules/workers/results/substitute.js +30 -0
- package/dist/modules/workers/runtime/env.js +43 -0
- package/dist/modules/workers/runtime/error-chain.js +109 -0
- package/dist/modules/workers/runtime/executor.js +289 -0
- package/dist/modules/workers/runtime/inspect.js +53 -0
- package/dist/modules/workers/runtime/protocol.js +1 -0
- package/dist/modules/workers/runtime/resolve.js +30 -0
- package/dist/modules/workers/runtime/schema.js +85 -0
- package/dist/modules/workers/runtime/thread.js +117 -0
- package/dist/modules/workers/start.js +265 -0
- package/dist/modules/workers/sync.js +241 -0
- package/dist/modules/workers/task-runner.js +892 -0
- package/dist/modules/workers/template.js +100 -0
- package/dist/modules/workers/worker-set.js +122 -0
- package/dist/modules/workers/workers.js +66 -0
- package/package.json +48 -5
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { DESCRIBE_MAX_DIFF_PATHS, DESCRIBE_MAX_GROUPS, DESCRIBE_MAX_MERGE, REPORT_ENVELOPE_RESERVE, REPORT_MAX_BYTES } from "../../../core/domain/index.js";
|
|
4
|
+
import { fitShape, ShapeMerger } from "./shape.js";
|
|
5
|
+
import { resultsDbPath } from "./store.js";
|
|
6
|
+
const RESULTS_CHILD = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './results-child.ts' : './results-child.js', import.meta.url));
|
|
7
|
+
export async function runQuery(root, sql, timeoutMs, signal) {
|
|
8
|
+
const r = await runResultsChild({ op: 'sql', file: resultsDbPath(root), sql }, timeoutMs, signal);
|
|
9
|
+
if (r === 'timeout') {
|
|
10
|
+
return {
|
|
11
|
+
ok: false,
|
|
12
|
+
kind: 'timeout',
|
|
13
|
+
message: `the query did not finish within ${timeoutMs / 1000}s`
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (r === 'cancelled')
|
|
17
|
+
return { ok: false, kind: 'cancelled', message: 'the query was cancelled' };
|
|
18
|
+
return r;
|
|
19
|
+
}
|
|
20
|
+
export async function runReaderChild(root, hostId, request, timeoutMs, signal) {
|
|
21
|
+
const r = await runResultsChild({ op: 'reader', root, hostId, file: resultsDbPath(root), request }, timeoutMs, signal);
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
24
|
+
function runResultsChild(message, timeoutMs, signal) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (signal?.aborted)
|
|
27
|
+
return resolve('cancelled');
|
|
28
|
+
const child = spawn(process.execPath, [RESULTS_CHILD], {
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
windowsHide: true
|
|
31
|
+
});
|
|
32
|
+
const out = [];
|
|
33
|
+
const err = [];
|
|
34
|
+
let timedOut = false;
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
timedOut = true;
|
|
37
|
+
child.kill('SIGKILL');
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
const onAbort = () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
child.kill('SIGKILL');
|
|
43
|
+
};
|
|
44
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
45
|
+
child.stdout.on('data', (c) => out.push(c));
|
|
46
|
+
child.stderr.on('data', (c) => err.push(c));
|
|
47
|
+
child.on('error', (e) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
reject(e);
|
|
50
|
+
});
|
|
51
|
+
child.on('close', (code) => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
signal?.removeEventListener('abort', onAbort);
|
|
54
|
+
if (cancelled)
|
|
55
|
+
return resolve('cancelled');
|
|
56
|
+
if (timedOut)
|
|
57
|
+
return resolve('timeout');
|
|
58
|
+
const text = Buffer.concat(out).toString('utf8');
|
|
59
|
+
try {
|
|
60
|
+
resolve(JSON.parse(text));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
const detail = Buffer.concat(err).toString('utf8').trim();
|
|
64
|
+
reject(new Error(`the query process ended without a result (exit code ${code})${detail ? `: ${detail}` : ''}`));
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
child.stdin.end(JSON.stringify(message));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export const DESCRIBE_MAX_BYTES = REPORT_MAX_BYTES - REPORT_ENVELOPE_RESERVE;
|
|
71
|
+
export function replacer(_key, value) {
|
|
72
|
+
if (typeof value === 'bigint')
|
|
73
|
+
return value.toString();
|
|
74
|
+
if (value instanceof Uint8Array)
|
|
75
|
+
return `<blob ${value.byteLength} bytes>`;
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
export const jsonBytes = (v) => Buffer.byteLength(JSON.stringify(v, replacer));
|
|
79
|
+
export function describeTask(r, maxBytes = DESCRIBE_MAX_BYTES) {
|
|
80
|
+
const build = (shape) => ({
|
|
81
|
+
task_id: r.task_id,
|
|
82
|
+
worker: r.worker,
|
|
83
|
+
created_at: r.created_at,
|
|
84
|
+
shape_hash: r.shape_hash ?? '',
|
|
85
|
+
shape
|
|
86
|
+
});
|
|
87
|
+
return build(fitShape(r.shape, (s) => jsonBytes(build(s)) <= maxBytes));
|
|
88
|
+
}
|
|
89
|
+
export function describeWorker(store, filter, maxBytes = DESCRIBE_MAX_BYTES, maxMerge = DESCRIBE_MAX_MERGE) {
|
|
90
|
+
return store.snapshot(() => describeIn(store, filter, maxBytes, maxMerge));
|
|
91
|
+
}
|
|
92
|
+
function describeIn(store, filter, maxBytes, maxMerge) {
|
|
93
|
+
const counted = store.shapeGroups(filter);
|
|
94
|
+
const total = counted.reduce((n, g) => n + g.count, 0);
|
|
95
|
+
const baseline = store.latestShapeHash(filter);
|
|
96
|
+
const baseGroup = counted.find((g) => g.shape_hash === baseline);
|
|
97
|
+
const ordered = baseGroup ? [baseGroup, ...counted.filter((g) => g !== baseGroup)] : counted;
|
|
98
|
+
let merged = null;
|
|
99
|
+
if (baseline !== null) {
|
|
100
|
+
const merger = new ShapeMerger();
|
|
101
|
+
let results = 0;
|
|
102
|
+
let since = '';
|
|
103
|
+
for (const r of store.recentShapes(filter, baseline, maxMerge)) {
|
|
104
|
+
if (results === 0)
|
|
105
|
+
since = r.created_at;
|
|
106
|
+
merger.add(JSON.parse(r.shape));
|
|
107
|
+
results++;
|
|
108
|
+
}
|
|
109
|
+
const shape = merger.result();
|
|
110
|
+
merged = { shape, paths: new Set(Object.keys(shape.paths)), results, since };
|
|
111
|
+
}
|
|
112
|
+
const cap = (list) => list.length > DESCRIBE_MAX_DIFF_PATHS
|
|
113
|
+
? {
|
|
114
|
+
list: list.slice(0, DESCRIBE_MAX_DIFF_PATHS),
|
|
115
|
+
more: list.length - DESCRIBE_MAX_DIFF_PATHS
|
|
116
|
+
}
|
|
117
|
+
: { list, more: 0 };
|
|
118
|
+
const pathsOf = (hash) => {
|
|
119
|
+
if (hash === baseline && merged)
|
|
120
|
+
return merged.paths;
|
|
121
|
+
for (const r of store.recentShapes(filter, hash, 1)) {
|
|
122
|
+
return new Set(Object.keys(JSON.parse(r.shape).paths));
|
|
123
|
+
}
|
|
124
|
+
return new Set();
|
|
125
|
+
};
|
|
126
|
+
const groups = ordered.slice(0, DESCRIBE_MAX_GROUPS).map((g) => {
|
|
127
|
+
const paths = pathsOf(g.shape_hash);
|
|
128
|
+
const base = merged?.paths;
|
|
129
|
+
const added = cap(base ? [...paths].filter((p) => !base.has(p)) : []);
|
|
130
|
+
const removed = cap(base ? [...base].filter((p) => !paths.has(p)) : []);
|
|
131
|
+
return {
|
|
132
|
+
shape_hash: g.shape_hash,
|
|
133
|
+
count: g.count,
|
|
134
|
+
first: g.first,
|
|
135
|
+
last: g.last,
|
|
136
|
+
task_ids: store.recentTaskIds(filter, g.shape_hash, 3),
|
|
137
|
+
added: added.list,
|
|
138
|
+
removed: removed.list,
|
|
139
|
+
...(added.more > 0 ? { added_more: added.more } : {}),
|
|
140
|
+
...(removed.more > 0 ? { removed_more: removed.more } : {})
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
const partial = merged !== null && baseGroup !== undefined && merged.results < baseGroup.count;
|
|
144
|
+
const build = (kept, shape) => {
|
|
145
|
+
const rest = ordered.slice(kept);
|
|
146
|
+
return {
|
|
147
|
+
worker: filter.worker ?? '',
|
|
148
|
+
total,
|
|
149
|
+
baseline,
|
|
150
|
+
groups: groups.slice(0, kept),
|
|
151
|
+
others: rest.length > 0
|
|
152
|
+
? { groups: rest.length, count: rest.reduce((n, g) => n + g.count, 0) }
|
|
153
|
+
: null,
|
|
154
|
+
shape,
|
|
155
|
+
shape_results: merged?.results ?? 0,
|
|
156
|
+
shape_since: merged?.since ?? null,
|
|
157
|
+
...(partial
|
|
158
|
+
? {
|
|
159
|
+
note: `values and ranges in shape cover the latest ${merged.results} of ${baseGroup.count} results ` +
|
|
160
|
+
`with this shape (since ${merged.since}). For all of them, query with json_extract — min/max or DISTINCT`
|
|
161
|
+
}
|
|
162
|
+
: {})
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
let kept = groups.length;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const fitted = merged
|
|
168
|
+
? fitShape(merged.shape, (s) => jsonBytes(build(kept, s)) <= maxBytes)
|
|
169
|
+
: null;
|
|
170
|
+
const out = build(kept, fitted);
|
|
171
|
+
if (kept === 0 || jsonBytes(out) <= maxBytes)
|
|
172
|
+
return out;
|
|
173
|
+
kept = Math.floor(kept / 2);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { BUILTIN_TASK_IDS_MAX, READ_LIMIT_MAX } from "../../../core/domain/index.js";
|
|
2
|
+
export const READER_USAGE = 'input is one of: {"op":"describe","task_id":"…","run_no?":1} | {"op":"describe","worker":"…","since?":"ISO date"} | ' +
|
|
3
|
+
'{"op":"query","sql":"SELECT … FROM results …","worker?":"…","task_id?":"…","task_ids?":["…"],"since?":"ISO date","expect_shape?":"…"} ' +
|
|
4
|
+
'(at least one of worker, task_id, task_ids, since) | {"op":"read","task_id":"…","run_no?":1,"path?":"$.items","offset?":0,"limit?":100} ' +
|
|
5
|
+
'(run_no picks a run of a repeating task; the latest run when left out)';
|
|
6
|
+
const TASK_ID = /^[0-9A-Za-z-]{1,64}$/;
|
|
7
|
+
const SHAPE_HASH = /^[0-9a-f]{16}$/;
|
|
8
|
+
const WORKER_MAX_CHARS = 50;
|
|
9
|
+
const SQL_MAX_CHARS = 20_000;
|
|
10
|
+
const PATH_MAX_CHARS = 1_000;
|
|
11
|
+
export function parseReaderInput(input) {
|
|
12
|
+
try {
|
|
13
|
+
return { ok: true, req: parse(input) };
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
return { ok: false, message: `${err.message}. ${READER_USAGE}` };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function parse(input) {
|
|
20
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
21
|
+
throw new Error('input must be a JSON object');
|
|
22
|
+
}
|
|
23
|
+
const o = input;
|
|
24
|
+
switch (o.op) {
|
|
25
|
+
case 'describe':
|
|
26
|
+
if (o.task_id !== undefined) {
|
|
27
|
+
only(o, ['op', 'task_id', 'run_no']);
|
|
28
|
+
return {
|
|
29
|
+
op: 'describe',
|
|
30
|
+
task_id: taskId(o.task_id, 'task_id'),
|
|
31
|
+
...(o.run_no !== undefined ? { run_no: runNo(o.run_no) } : {})
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (o.worker !== undefined) {
|
|
35
|
+
only(o, ['op', 'worker', 'since']);
|
|
36
|
+
return {
|
|
37
|
+
op: 'describe',
|
|
38
|
+
worker: worker(o.worker),
|
|
39
|
+
...(o.since !== undefined ? { since: since(o.since) } : {})
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
throw new Error('describe needs task_id or worker');
|
|
43
|
+
case 'query': {
|
|
44
|
+
only(o, ['op', 'sql', 'worker', 'task_id', 'task_ids', 'since', 'expect_shape']);
|
|
45
|
+
if (typeof o.sql !== 'string' || o.sql.trim() === '')
|
|
46
|
+
throw new Error('sql must be a string');
|
|
47
|
+
if (o.sql.length > SQL_MAX_CHARS)
|
|
48
|
+
throw new Error(`sql is longer than ${SQL_MAX_CHARS} characters`);
|
|
49
|
+
const ids = [];
|
|
50
|
+
if (o.task_id !== undefined)
|
|
51
|
+
ids.push(taskId(o.task_id, 'task_id'));
|
|
52
|
+
if (o.task_ids !== undefined) {
|
|
53
|
+
if (!Array.isArray(o.task_ids) || o.task_ids.length === 0) {
|
|
54
|
+
throw new Error('task_ids must be a non-empty array');
|
|
55
|
+
}
|
|
56
|
+
for (const id of o.task_ids)
|
|
57
|
+
ids.push(taskId(id, 'task_ids[]'));
|
|
58
|
+
}
|
|
59
|
+
if (ids.length > BUILTIN_TASK_IDS_MAX) {
|
|
60
|
+
throw new Error(`at most ${BUILTIN_TASK_IDS_MAX} task ids — use worker and since instead`);
|
|
61
|
+
}
|
|
62
|
+
const scope = {
|
|
63
|
+
...(o.worker !== undefined ? { worker: worker(o.worker) } : {}),
|
|
64
|
+
...(ids.length > 0 ? { task_ids: [...new Set(ids)] } : {}),
|
|
65
|
+
...(o.since !== undefined ? { since: since(o.since) } : {})
|
|
66
|
+
};
|
|
67
|
+
if (Object.keys(scope).length === 0) {
|
|
68
|
+
throw new Error('query needs a scope: worker, task_id, task_ids or since');
|
|
69
|
+
}
|
|
70
|
+
if (o.expect_shape !== undefined) {
|
|
71
|
+
if (typeof o.expect_shape !== 'string' || !SHAPE_HASH.test(o.expect_shape)) {
|
|
72
|
+
throw new Error('expect_shape must be a shape_hash (16 hex characters)');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
op: 'query',
|
|
77
|
+
sql: o.sql,
|
|
78
|
+
scope,
|
|
79
|
+
...(o.expect_shape !== undefined ? { expect_shape: o.expect_shape } : {})
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
case 'read': {
|
|
83
|
+
only(o, ['op', 'task_id', 'run_no', 'path', 'offset', 'limit']);
|
|
84
|
+
const path = o.path === undefined ? '$' : o.path;
|
|
85
|
+
if (typeof path !== 'string' || !path.startsWith('$') || path.length > PATH_MAX_CHARS) {
|
|
86
|
+
throw new Error('path must be a JSON path starting with $ (for example $.items)');
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
op: 'read',
|
|
90
|
+
task_id: taskId(o.task_id, 'task_id'),
|
|
91
|
+
...(o.run_no !== undefined ? { run_no: runNo(o.run_no) } : {}),
|
|
92
|
+
path,
|
|
93
|
+
offset: o.offset === undefined ? 0 : count(o.offset, 'offset', 0, Number.MAX_SAFE_INTEGER),
|
|
94
|
+
limit: o.limit === undefined ? null : count(o.limit, 'limit', 1, READ_LIMIT_MAX)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
throw new Error('op must be "describe", "query" or "read"');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function runNo(v) {
|
|
102
|
+
return count(v, 'run_no', 1, Number.MAX_SAFE_INTEGER);
|
|
103
|
+
}
|
|
104
|
+
function only(o, keys) {
|
|
105
|
+
const extra = Object.keys(o).filter((k) => !keys.includes(k));
|
|
106
|
+
if (extra.length > 0)
|
|
107
|
+
throw new Error(`unknown field ${extra.map((k) => `"${k}"`).join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
function taskId(v, field) {
|
|
110
|
+
if (typeof v !== 'string' || !TASK_ID.test(v))
|
|
111
|
+
throw new Error(`${field} must be a task id`);
|
|
112
|
+
return v;
|
|
113
|
+
}
|
|
114
|
+
function worker(v) {
|
|
115
|
+
if (typeof v !== 'string' ||
|
|
116
|
+
v.length === 0 ||
|
|
117
|
+
Array.from(v).length > WORKER_MAX_CHARS ||
|
|
118
|
+
v.includes('\0')) {
|
|
119
|
+
throw new Error('worker must be a worker name');
|
|
120
|
+
}
|
|
121
|
+
return v;
|
|
122
|
+
}
|
|
123
|
+
function since(v) {
|
|
124
|
+
const t = typeof v === 'string' ? Date.parse(v) : Number.NaN;
|
|
125
|
+
if (Number.isNaN(t))
|
|
126
|
+
throw new Error('since must be an ISO date or time (for example 2026-10-01)');
|
|
127
|
+
return new Date(t).toISOString();
|
|
128
|
+
}
|
|
129
|
+
function count(v, field, min, max) {
|
|
130
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max) {
|
|
131
|
+
throw new Error(`${field} must be an integer from ${min} to ${max}`);
|
|
132
|
+
}
|
|
133
|
+
return v;
|
|
134
|
+
}
|