pi-webdesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/apps/daemon/src/appearance-preferences.js +218 -0
- package/dist/apps/daemon/src/auth.js +88 -0
- package/dist/apps/daemon/src/bin.js +123 -0
- package/dist/apps/daemon/src/cli.js +48 -0
- package/dist/apps/daemon/src/event-hub.js +155 -0
- package/dist/apps/daemon/src/index.js +102 -0
- package/dist/apps/daemon/src/launcher-control.js +114 -0
- package/dist/apps/daemon/src/launcher.js +73 -0
- package/dist/apps/daemon/src/pi-auth.js +290 -0
- package/dist/apps/daemon/src/pi-resources.js +182 -0
- package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
- package/dist/apps/daemon/src/pi-sessions.js +265 -0
- package/dist/apps/daemon/src/runtime-process.js +241 -0
- package/dist/apps/daemon/src/secret.js +71 -0
- package/dist/apps/daemon/src/server.js +1662 -0
- package/dist/apps/daemon/src/session-projection.js +117 -0
- package/dist/apps/daemon/src/state-lock.js +31 -0
- package/dist/apps/daemon/src/static-web.js +53 -0
- package/dist/apps/daemon/src/task-archive.js +152 -0
- package/dist/apps/daemon/src/task-commit.js +503 -0
- package/dist/apps/daemon/src/task-merge.js +912 -0
- package/dist/apps/daemon/src/task-review.js +204 -0
- package/dist/apps/daemon/src/task-runtime.js +1124 -0
- package/dist/apps/daemon/src/task-validation.js +352 -0
- package/dist/apps/daemon/src/workspace-store.js +140 -0
- package/dist/apps/daemon/src/workspace.js +795 -0
- package/dist/extensions/webdesk.js +34 -0
- package/dist/packages/git/src/commit.js +675 -0
- package/dist/packages/git/src/errors.js +55 -0
- package/dist/packages/git/src/fingerprint.js +286 -0
- package/dist/packages/git/src/index.js +123 -0
- package/dist/packages/git/src/merge.js +1008 -0
- package/dist/packages/git/src/paths.js +58 -0
- package/dist/packages/git/src/repository.js +77 -0
- package/dist/packages/git/src/review.js +396 -0
- package/dist/packages/git/src/runner.js +110 -0
- package/dist/packages/git/src/validation.js +263 -0
- package/dist/packages/git/src/worktree.js +233 -0
- package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
- package/dist/packages/pi-bridge/src/auth.js +80 -0
- package/dist/packages/pi-bridge/src/errors.js +19 -0
- package/dist/packages/pi-bridge/src/handshake.js +43 -0
- package/dist/packages/pi-bridge/src/index.js +76 -0
- package/dist/packages/pi-bridge/src/jsonl.js +105 -0
- package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
- package/dist/packages/pi-bridge/src/resolve.js +59 -0
- package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
- package/dist/packages/pi-bridge/src/resources.js +481 -0
- package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
- package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
- package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
- package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
- package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
- package/dist/packages/pi-bridge/src/runtime.js +0 -0
- package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
- package/dist/packages/pi-bridge/src/sessions.js +314 -0
- package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
- package/dist/packages/protocol/src/index.js +1863 -0
- package/dist/web/assets/index-BOw_fhvO.css +2 -0
- package/dist/web/assets/index-oXs7yAAo.js +119 -0
- package/dist/web/index.html +14 -0
- package/package.json +69 -0
- package/scripts/prepare.mjs +7 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// apps/daemon/src/session-projection.ts
|
|
2
|
+
import { sanitizeApprovalDisplayText } from "../../../packages/pi-bridge/src/index.js";
|
|
3
|
+
import {
|
|
4
|
+
MAX_SESSION_TRANSCRIPT_ITEMS,
|
|
5
|
+
MAX_SESSION_TRANSCRIPT_TEXT_CHARS
|
|
6
|
+
} from "../../../packages/protocol/src/index.js";
|
|
7
|
+
var PREVIEW_LIMIT = 160;
|
|
8
|
+
function clip(text, limit) {
|
|
9
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}\u2026` : text;
|
|
10
|
+
}
|
|
11
|
+
function buildTranscript(session) {
|
|
12
|
+
const byId = new Map(session.entries.map((entry) => [entry.id, entry]));
|
|
13
|
+
const branch = [];
|
|
14
|
+
const visited = /* @__PURE__ */ new Set();
|
|
15
|
+
let cursor = session.leafId;
|
|
16
|
+
while (cursor !== null && !visited.has(cursor)) {
|
|
17
|
+
visited.add(cursor);
|
|
18
|
+
const entry = byId.get(cursor);
|
|
19
|
+
if (entry === void 0) break;
|
|
20
|
+
branch.push(entry);
|
|
21
|
+
cursor = entry.parentId;
|
|
22
|
+
}
|
|
23
|
+
branch.reverse();
|
|
24
|
+
const activePath = [];
|
|
25
|
+
const calls = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const entry of branch) {
|
|
27
|
+
if ((entry.role === "user" || entry.role === "assistant") && entry.text) {
|
|
28
|
+
const rawPrefix = entry.text.slice(0, MAX_SESSION_TRANSCRIPT_TEXT_CHARS);
|
|
29
|
+
const safeText = sanitizeApprovalDisplayText(rawPrefix);
|
|
30
|
+
activePath.push({
|
|
31
|
+
id: entry.id,
|
|
32
|
+
role: entry.role,
|
|
33
|
+
text: safeText.slice(0, MAX_SESSION_TRANSCRIPT_TEXT_CHARS),
|
|
34
|
+
omittedChars: entry.text.length - rawPrefix.length + Math.max(0, safeText.length - MAX_SESSION_TRANSCRIPT_TEXT_CHARS)
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
for (const tool of entry.toolCalls ?? []) {
|
|
38
|
+
calls.set(tool.toolCallId, activePath.length);
|
|
39
|
+
activePath.push({ id: tool.toolCallId, role: "tool", tool: { ...tool, status: "incomplete" } });
|
|
40
|
+
}
|
|
41
|
+
if (entry.toolResult) {
|
|
42
|
+
const result = entry.toolResult;
|
|
43
|
+
const index = calls.get(result.toolCallId);
|
|
44
|
+
const existing = index === void 0 ? void 0 : activePath[index];
|
|
45
|
+
const tool = {
|
|
46
|
+
...existing?.role === "tool" ? existing.tool : {
|
|
47
|
+
toolCallId: result.toolCallId,
|
|
48
|
+
name: result.name,
|
|
49
|
+
input: "",
|
|
50
|
+
inputOmittedChars: 0
|
|
51
|
+
},
|
|
52
|
+
status: result.isError ? "failed" : "succeeded",
|
|
53
|
+
output: result.output,
|
|
54
|
+
outputOmittedChars: result.omittedChars
|
|
55
|
+
};
|
|
56
|
+
const item = { id: result.toolCallId, role: "tool", tool };
|
|
57
|
+
if (index === void 0) activePath.push(item);
|
|
58
|
+
else activePath[index] = item;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const omittedItems = Math.max(
|
|
62
|
+
0,
|
|
63
|
+
activePath.length - MAX_SESSION_TRANSCRIPT_ITEMS
|
|
64
|
+
);
|
|
65
|
+
return { items: activePath.slice(omittedItems), omittedItems };
|
|
66
|
+
}
|
|
67
|
+
function buildSessionTree(tree) {
|
|
68
|
+
const parents = /* @__PURE__ */ new Map();
|
|
69
|
+
const pendingParents = [...tree.roots];
|
|
70
|
+
while (pendingParents.length > 0) {
|
|
71
|
+
const node = pendingParents.pop();
|
|
72
|
+
parents.set(node.entry.id, node.entry.parentId);
|
|
73
|
+
for (const child of node.children) pendingParents.push(child);
|
|
74
|
+
}
|
|
75
|
+
const activeIds = /* @__PURE__ */ new Set();
|
|
76
|
+
let cursor = tree.leafId;
|
|
77
|
+
while (cursor !== null && !activeIds.has(cursor)) {
|
|
78
|
+
activeIds.add(cursor);
|
|
79
|
+
cursor = parents.get(cursor) ?? null;
|
|
80
|
+
}
|
|
81
|
+
const makeNode = (node) => ({
|
|
82
|
+
id: node.entry.id,
|
|
83
|
+
parentId: node.entry.parentId,
|
|
84
|
+
kind: sanitizeApprovalDisplayText(node.entry.type).slice(0, 100),
|
|
85
|
+
...node.entry.role === void 0 ? {} : { role: sanitizeApprovalDisplayText(node.entry.role).slice(0, 100) },
|
|
86
|
+
...node.label === void 0 ? {} : { label: clip(sanitizeApprovalDisplayText(node.label), 500) },
|
|
87
|
+
...node.entry.text === void 0 || node.entry.text === "" ? {} : {
|
|
88
|
+
preview: clip(
|
|
89
|
+
sanitizeApprovalDisplayText(node.entry.text.slice(0, PREVIEW_LIMIT)),
|
|
90
|
+
PREVIEW_LIMIT
|
|
91
|
+
)
|
|
92
|
+
},
|
|
93
|
+
onActivePath: activeIds.has(node.entry.id),
|
|
94
|
+
children: []
|
|
95
|
+
});
|
|
96
|
+
const roots = tree.roots.map(makeNode);
|
|
97
|
+
const pendingNodes = tree.roots.map((source, index) => ({ source, target: roots[index] }));
|
|
98
|
+
while (pendingNodes.length > 0) {
|
|
99
|
+
const { source, target } = pendingNodes.pop();
|
|
100
|
+
target.children = source.children.map(makeNode);
|
|
101
|
+
for (let index = 0; index < source.children.length; index++) {
|
|
102
|
+
pendingNodes.push({
|
|
103
|
+
source: source.children[index],
|
|
104
|
+
target: target.children[index]
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
roots,
|
|
110
|
+
leafId: tree.leafId,
|
|
111
|
+
truncated: tree.truncated
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
export {
|
|
115
|
+
buildSessionTree,
|
|
116
|
+
buildTranscript
|
|
117
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// apps/daemon/src/state-lock.ts
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { lock } from "proper-lockfile";
|
|
4
|
+
var LOCK_DIRECTORY = "daemon.lock";
|
|
5
|
+
var STALE_LOCK_MS = 3e4;
|
|
6
|
+
var LOCK_HEARTBEAT_MS = 5e3;
|
|
7
|
+
var DaemonStateLockError = class extends Error {
|
|
8
|
+
name = "DaemonStateLockError";
|
|
9
|
+
};
|
|
10
|
+
async function acquireDaemonStateLock(stateDir) {
|
|
11
|
+
const resolvedStateDir = path.resolve(stateDir);
|
|
12
|
+
try {
|
|
13
|
+
return await lock(resolvedStateDir, {
|
|
14
|
+
lockfilePath: path.join(resolvedStateDir, LOCK_DIRECTORY),
|
|
15
|
+
realpath: true,
|
|
16
|
+
stale: STALE_LOCK_MS,
|
|
17
|
+
update: LOCK_HEARTBEAT_MS,
|
|
18
|
+
retries: 0
|
|
19
|
+
});
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const code = error !== null && typeof error === "object" && "code" in error ? String(error.code) : null;
|
|
22
|
+
throw new DaemonStateLockError(
|
|
23
|
+
code === "ELOCKED" ? `Another Webdesk daemon is already using ${resolvedStateDir}. Stop it before starting a daemon with the same state directory.` : `Webdesk could not claim exclusive ownership of ${resolvedStateDir}. Check the state directory and daemon output before retrying.`,
|
|
24
|
+
{ cause: error }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
DaemonStateLockError,
|
|
30
|
+
acquireDaemonStateLock
|
|
31
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// apps/daemon/src/static-web.ts
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
var CONTENT_TYPES = {
|
|
6
|
+
".html": "text/html; charset=utf-8",
|
|
7
|
+
".js": "text/javascript; charset=utf-8",
|
|
8
|
+
".css": "text/css; charset=utf-8",
|
|
9
|
+
".svg": "image/svg+xml",
|
|
10
|
+
".png": "image/png",
|
|
11
|
+
".ico": "image/x-icon",
|
|
12
|
+
".woff2": "font/woff2"
|
|
13
|
+
};
|
|
14
|
+
function createStaticWebHandler(directory) {
|
|
15
|
+
const root = realpathSync(directory);
|
|
16
|
+
return async (req, res) => {
|
|
17
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
18
|
+
let pathname;
|
|
19
|
+
try {
|
|
20
|
+
pathname = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (!pathname.startsWith("/") || pathname.includes("\\") || pathname.includes("\0")) return false;
|
|
25
|
+
const parts = pathname.split("/");
|
|
26
|
+
if (parts.some((part) => part.startsWith("."))) return false;
|
|
27
|
+
if (pathname !== "/" && pathname !== "/pi" && pathname !== "/index.html" && !pathname.startsWith("/assets/")) return false;
|
|
28
|
+
const base = root;
|
|
29
|
+
try {
|
|
30
|
+
const file = await realpath(path.join(base, pathname === "/" || pathname === "/pi" ? "index.html" : pathname));
|
|
31
|
+
const relative = path.relative(base, file);
|
|
32
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
|
|
33
|
+
const type = CONTENT_TYPES[path.extname(file)];
|
|
34
|
+
if (!type || !(await stat(file)).isFile()) return false;
|
|
35
|
+
const body = await readFile(file);
|
|
36
|
+
res.writeHead(200, {
|
|
37
|
+
"content-type": type,
|
|
38
|
+
"content-length": body.length,
|
|
39
|
+
"cache-control": "no-store",
|
|
40
|
+
"referrer-policy": "no-referrer",
|
|
41
|
+
"x-content-type-options": "nosniff"
|
|
42
|
+
});
|
|
43
|
+
res.end(req.method === "HEAD" ? void 0 : body);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (["ENOENT", "ENOTDIR", "EACCES"].includes(error.code ?? "")) return false;
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
createStaticWebHandler
|
|
53
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// apps/daemon/src/task-archive.ts
|
|
2
|
+
import { realpath as resolveRealpath } from "node:fs/promises";
|
|
3
|
+
import { inspectRepository } from "../../../packages/git/src/index.js";
|
|
4
|
+
import { WorkspaceOperationError } from "./workspace.js";
|
|
5
|
+
import { TaskRuntimeOperationError } from "./task-runtime.js";
|
|
6
|
+
function createTaskArchiveService(options) {
|
|
7
|
+
const inspect = options.inspectRepository ?? ((rootPath) => inspectRepository(rootPath));
|
|
8
|
+
const realpath = options.realpath ?? resolveRealpath;
|
|
9
|
+
const active = /* @__PURE__ */ new Set();
|
|
10
|
+
function claim(taskId) {
|
|
11
|
+
if (active.has(taskId)) {
|
|
12
|
+
throw new WorkspaceOperationError(
|
|
13
|
+
"task-busy",
|
|
14
|
+
"Another archive or restore operation is already active for this task.",
|
|
15
|
+
409
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
active.add(taskId);
|
|
19
|
+
}
|
|
20
|
+
function assertOtherLifecycleIdle(taskId, repositoryId) {
|
|
21
|
+
if (options.isRuntimeMutating(taskId) || options.isValidationRunning(taskId) || options.isCommitActive(taskId) || options.isMergeActive(taskId) || repositoryId !== void 0 && options.isMergeRepositoryActive(repositoryId)) {
|
|
22
|
+
throw new WorkspaceOperationError(
|
|
23
|
+
"task-busy",
|
|
24
|
+
"Wait for the active Pi, validation, commit, or merge operation to settle before changing this task's lifecycle.",
|
|
25
|
+
409
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function requireTask(taskId) {
|
|
30
|
+
const task = await options.getTask(taskId);
|
|
31
|
+
if (task === null) {
|
|
32
|
+
throw new WorkspaceOperationError(
|
|
33
|
+
"task-not-found",
|
|
34
|
+
"The selected task no longer exists.",
|
|
35
|
+
404
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return task;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
isBusy(taskId) {
|
|
42
|
+
return active.has(taskId);
|
|
43
|
+
},
|
|
44
|
+
async archive(taskId) {
|
|
45
|
+
claim(taskId);
|
|
46
|
+
try {
|
|
47
|
+
assertOtherLifecycleIdle(taskId);
|
|
48
|
+
const task = await requireTask(taskId);
|
|
49
|
+
assertOtherLifecycleIdle(taskId, task.repositoryId);
|
|
50
|
+
if (task.status === "archived") return options.archiveTask(taskId);
|
|
51
|
+
if (task.status !== "ready") {
|
|
52
|
+
throw new WorkspaceOperationError(
|
|
53
|
+
"task-not-ready",
|
|
54
|
+
`Only a ready task can be archived; this task is ${task.status}.`,
|
|
55
|
+
409
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const repository = await options.getRepository(task.repositoryId);
|
|
59
|
+
if (repository === null) {
|
|
60
|
+
throw new WorkspaceOperationError(
|
|
61
|
+
"task-recovery-required",
|
|
62
|
+
"The task's registered repository is unavailable, so it was not archived.",
|
|
63
|
+
409
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (repository.pendingMerge?.taskId === task.id) {
|
|
67
|
+
throw new WorkspaceOperationError(
|
|
68
|
+
"task-busy",
|
|
69
|
+
"Reconcile the task's interrupted merge before archiving it.",
|
|
70
|
+
409
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
await options.disposeRuntime(taskId);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof TaskRuntimeOperationError) {
|
|
77
|
+
throw new WorkspaceOperationError(
|
|
78
|
+
error.code === "runtime-busy" ? "task-busy" : "task-recovery-required",
|
|
79
|
+
error.message,
|
|
80
|
+
error.status,
|
|
81
|
+
{ cause: error }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
const stopped = await requireTask(taskId);
|
|
87
|
+
if (stopped.status !== "ready" || stopped.runtimeProcess !== void 0) {
|
|
88
|
+
throw new WorkspaceOperationError(
|
|
89
|
+
"task-recovery-required",
|
|
90
|
+
"Webdesk could not prove that the task runtime stopped cleanly, so the task was not archived.",
|
|
91
|
+
409
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
options.invalidateCommitPreflight(taskId);
|
|
95
|
+
options.invalidateMergePreflight(taskId);
|
|
96
|
+
return await options.archiveTask(taskId);
|
|
97
|
+
} finally {
|
|
98
|
+
active.delete(taskId);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
async restore(taskId) {
|
|
102
|
+
claim(taskId);
|
|
103
|
+
try {
|
|
104
|
+
assertOtherLifecycleIdle(taskId);
|
|
105
|
+
const task = await requireTask(taskId);
|
|
106
|
+
assertOtherLifecycleIdle(taskId, task.repositoryId);
|
|
107
|
+
if (task.status === "ready") return options.restoreTask(taskId, 0);
|
|
108
|
+
if (task.status !== "archived") {
|
|
109
|
+
throw new WorkspaceOperationError(
|
|
110
|
+
"task-not-ready",
|
|
111
|
+
`Only an archived task can be restored; this task is ${task.status}.`,
|
|
112
|
+
409
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const repository = await options.getRepository(task.repositoryId);
|
|
116
|
+
if (repository === null) {
|
|
117
|
+
throw new WorkspaceOperationError(
|
|
118
|
+
"task-recovery-required",
|
|
119
|
+
"The task's registered repository is unavailable, so it remains archived.",
|
|
120
|
+
409
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
let canonicalRoot;
|
|
124
|
+
let worktree;
|
|
125
|
+
try {
|
|
126
|
+
canonicalRoot = await realpath(task.worktreePath);
|
|
127
|
+
worktree = await inspect(canonicalRoot);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
throw new WorkspaceOperationError(
|
|
130
|
+
"task-recovery-required",
|
|
131
|
+
"The archived task worktree is unavailable or no longer a valid Git worktree.",
|
|
132
|
+
409,
|
|
133
|
+
{ cause: error }
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (canonicalRoot !== task.worktreePath || worktree.root !== canonicalRoot || worktree.commonDir !== repository.commonDir || worktree.branch !== task.branch) {
|
|
137
|
+
throw new WorkspaceOperationError(
|
|
138
|
+
"task-recovery-required",
|
|
139
|
+
"The archived worktree no longer matches its recorded path, repository, or branch, so it was not restored.",
|
|
140
|
+
409
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return await options.restoreTask(taskId, task.archivedAtMs);
|
|
144
|
+
} finally {
|
|
145
|
+
active.delete(taskId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export {
|
|
151
|
+
createTaskArchiveService
|
|
152
|
+
};
|