@playcraft/cli 0.1.9 → 0.1.11
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 +11 -0
- package/dist/auth/account-activation.js +157 -0
- package/dist/auth/messages.js +30 -0
- package/dist/commands/login.js +5 -0
- package/dist/commands/workspace-runtime.js +1 -1
- package/dist/index.js +3 -0
- package/dist/utils/agent-api-client.js +24 -1
- package/dist/utils/playcraft-http-client.js +6 -4
- package/dist/utils/tool-operation-journal.js +4 -0
- package/dist/workspace-runtime/codex/app-server.js +16 -0
- package/dist/workspace-runtime/codex/transcripts.js +94 -0
- package/dist/workspace-runtime/previews/publish.js +24 -4
- package/dist/workspace-runtime/serve.js +26 -2
- package/dist/workspace-runtime/server/dispatch.js +69 -8
- package/dist/workspace-runtime/server/endpoints.js +19 -0
- package/dist/workspace-runtime/server/http.js +15 -1
- package/dist/workspace-runtime/workspaces/checkpoint-saves.js +79 -0
- package/dist/workspace-runtime/workspaces/file-snapshot.js +160 -21
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +5 -1
- package/dist/workspace-runtime/workspaces/git.js +91 -1
- package/dist/workspace-runtime/workspaces/prepare.js +31 -22
- package/dist/workspace-runtime/workspaces/registry.js +14 -5
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +25 -25
package/README.md
CHANGED
|
@@ -141,6 +141,17 @@ playcraft login --url https://your-playcraft.example.com
|
|
|
141
141
|
|
|
142
142
|
登录成功后,`tools`、`recommend`、`fix-ids` 等所有需要后端 API 的命令均无需额外配置,直接使用。
|
|
143
143
|
|
|
144
|
+
账号提示待激活时,使用同一目标环境及已保存的个人访问令牌主动激活:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
playcraft login --activate
|
|
148
|
+
playcraft login --activate --url https://your-playcraft.example.com
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
也可通过 `PLAYCRAFT_TOKEN` 提供个人访问令牌。激活只发送一次请求,成功后重新执行原业务命令;该动作不会打开浏览器或续签令牌。目标服务须已接入身份服务的激活入口;封禁、停用账号及沙箱执行令牌不能用于激活。
|
|
152
|
+
|
|
153
|
+
若本地、全局或环境变量配置冲突,激活会要求明确指定 `--url`、所需的 `PLAYCRAFT_ENV` 和对应的个人访问令牌。
|
|
154
|
+
|
|
144
155
|
### 沙箱环境(CI / Agent)
|
|
145
156
|
|
|
146
157
|
在沙箱或 CI 中可通过 `.playcraft.json` 或环境变量配置:
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { loadGlobalConfig } from '../config.js';
|
|
5
|
+
import { accountActivationMessage } from './messages.js';
|
|
6
|
+
export const ACCOUNT_PENDING_ACTIVATION = 'account_pending_activation';
|
|
7
|
+
export class AccountActivationError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
status;
|
|
10
|
+
retryable = false;
|
|
11
|
+
constructor(code, message, status) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.name = 'AccountActivationError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function responseErrorCode(payload) {
|
|
19
|
+
if (!payload || typeof payload !== 'object')
|
|
20
|
+
return undefined;
|
|
21
|
+
const { error, code } = payload;
|
|
22
|
+
return typeof error?.code === 'string' ? error.code : typeof code === 'string' ? code : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function pendingAccountActivationError(status, payload) {
|
|
25
|
+
return status === 403 && responseErrorCode(payload) === ACCOUNT_PENDING_ACTIVATION
|
|
26
|
+
? new AccountActivationError(ACCOUNT_PENDING_ACTIVATION, `${ACCOUNT_PENDING_ACTIVATION}: ${accountActivationMessage('pending')}`, status)
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
export function parseErrorPayload(text) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(text);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function localActivationConfig() {
|
|
38
|
+
for (const path of [
|
|
39
|
+
join(process.cwd(), '.playcraft.json'),
|
|
40
|
+
'/project/.playcraft.json',
|
|
41
|
+
join(homedir(), '.playcraft.json'),
|
|
42
|
+
]) {
|
|
43
|
+
if (!existsSync(path))
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
const config = JSON.parse(readFileSync(path, 'utf8'));
|
|
47
|
+
if (config && typeof config === 'object' && !Array.isArray(config))
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Follow the existing local credential lookup when a file is unreadable.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
function stringValue(value) {
|
|
57
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
function activationBaseUrl(raw) {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(raw);
|
|
62
|
+
if (!['http:', 'https:'].includes(url.protocol) ||
|
|
63
|
+
url.username ||
|
|
64
|
+
url.password ||
|
|
65
|
+
url.search ||
|
|
66
|
+
url.hash)
|
|
67
|
+
throw new Error();
|
|
68
|
+
url.pathname = url.pathname.replace(/\/+$/, '').replace(/\/api$/, '');
|
|
69
|
+
return url.toString().replace(/\/+$/, '');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new AccountActivationError('INVALID_ACTIVATION_TARGET', accountActivationMessage('invalidTarget'));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function savedTokenForTarget(config, rawUrl, target, environment) {
|
|
76
|
+
if (!rawUrl || (stringValue(config.env)?.trim() || '') !== environment)
|
|
77
|
+
return undefined;
|
|
78
|
+
try {
|
|
79
|
+
return activationBaseUrl(rawUrl) === target ? stringValue(config.token) : undefined;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function normalizedSavedUrl(raw) {
|
|
86
|
+
try {
|
|
87
|
+
return raw ? activationBaseUrl(raw) : undefined;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function activateAccount(options, defaultBaseUrl) {
|
|
94
|
+
const local = localActivationConfig();
|
|
95
|
+
const global = loadGlobalConfig();
|
|
96
|
+
const localUrl = stringValue(local.apiUrl);
|
|
97
|
+
const globalUrl = stringValue(global.backendUrl) || stringValue(global.url);
|
|
98
|
+
const explicitUrl = options.url || process.env.PLAYCRAFT_API_URL || process.env.PLAYCRAFT_URL;
|
|
99
|
+
const target = activationBaseUrl(explicitUrl || localUrl || globalUrl || defaultBaseUrl);
|
|
100
|
+
const explicitToken = options.token || process.env.PLAYCRAFT_TOKEN || process.env.PLAYCRAFT_SANDBOX_TOKEN;
|
|
101
|
+
const savedUrls = [normalizedSavedUrl(localUrl), normalizedSavedUrl(globalUrl)].filter(Boolean);
|
|
102
|
+
const savedTokens = [stringValue(local.token), stringValue(global.token)].filter(Boolean);
|
|
103
|
+
const environmentsConflict = savedUrls.length === 2 &&
|
|
104
|
+
savedUrls[0] === savedUrls[1] &&
|
|
105
|
+
(stringValue(local.env)?.trim() || '') !== (stringValue(global.env)?.trim() || '');
|
|
106
|
+
const environmentTokensConflict = process.env.PLAYCRAFT_TOKEN &&
|
|
107
|
+
process.env.PLAYCRAFT_SANDBOX_TOKEN &&
|
|
108
|
+
process.env.PLAYCRAFT_TOKEN !== process.env.PLAYCRAFT_SANDBOX_TOKEN;
|
|
109
|
+
const environmentUrlsConflict = process.env.PLAYCRAFT_API_URL &&
|
|
110
|
+
process.env.PLAYCRAFT_URL &&
|
|
111
|
+
normalizedSavedUrl(process.env.PLAYCRAFT_API_URL) !==
|
|
112
|
+
normalizedSavedUrl(process.env.PLAYCRAFT_URL);
|
|
113
|
+
if ((!explicitUrl && new Set(savedUrls).size > 1) ||
|
|
114
|
+
(!explicitToken && new Set(savedTokens).size > 1) ||
|
|
115
|
+
(environmentsConflict && !process.env.PLAYCRAFT_ENV) ||
|
|
116
|
+
(environmentTokensConflict && !options.token) ||
|
|
117
|
+
(environmentUrlsConflict && !options.url)) {
|
|
118
|
+
throw new AccountActivationError('AMBIGUOUS_ACTIVATION_TARGET', accountActivationMessage('ambiguous'));
|
|
119
|
+
}
|
|
120
|
+
const environment = (process.env.PLAYCRAFT_ENV ||
|
|
121
|
+
(!explicitUrl ? (localUrl ? stringValue(local.env) : stringValue(global.env)) : '') ||
|
|
122
|
+
'').trim();
|
|
123
|
+
const token = explicitToken ||
|
|
124
|
+
savedTokenForTarget(local, localUrl, target, environment) ||
|
|
125
|
+
savedTokenForTarget(global, globalUrl, target, environment);
|
|
126
|
+
if (!token)
|
|
127
|
+
throw new AccountActivationError('AUTH_REQUIRED', accountActivationMessage('missingToken'));
|
|
128
|
+
if (!/^playcraft_pat_[A-Za-z0-9_-]{32}$/.test(token)) {
|
|
129
|
+
throw new AccountActivationError('INVALID_ACTIVATION_TOKEN', accountActivationMessage('invalidToken'));
|
|
130
|
+
}
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetch(`${target}/api/settings/personal-access-tokens/activate`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
redirect: 'error',
|
|
136
|
+
signal: AbortSignal.timeout(30_000),
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: `Bearer ${token}`,
|
|
139
|
+
...(environment ? { 'playcraft-env': environment } : {}),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new AccountActivationError('ACTIVATION_UNCONFIRMED', accountActivationMessage('unavailable'));
|
|
145
|
+
}
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
await response.body?.cancel().catch(() => { });
|
|
148
|
+
throw new AccountActivationError('ACTIVATION_FAILED', accountActivationMessage('failed'), response.status);
|
|
149
|
+
}
|
|
150
|
+
const result = await response.json().catch(() => undefined);
|
|
151
|
+
if (!result ||
|
|
152
|
+
typeof result !== 'object' ||
|
|
153
|
+
result.accountStatus !== 'active') {
|
|
154
|
+
throw new AccountActivationError('ACTIVATION_UNCONFIRMED', accountActivationMessage('unavailable'), response.status);
|
|
155
|
+
}
|
|
156
|
+
console.log(accountActivationMessage('success'));
|
|
157
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const messages = {
|
|
2
|
+
en: {
|
|
3
|
+
activateOption: 'Explicitly activate the account using the current personal access token',
|
|
4
|
+
tokenOption: 'Personal access token for --activate (or use PLAYCRAFT_TOKEN)',
|
|
5
|
+
pending: 'Account activation is required. Run `playcraft login --activate` with the same token and target environment, then rerun your command.',
|
|
6
|
+
success: 'Account activated. Rerun your original command to continue.',
|
|
7
|
+
missingToken: 'No personal access token matches this target environment. Provide PLAYCRAFT_TOKEN or log in to this environment first.',
|
|
8
|
+
invalidToken: 'Account activation requires a personal access token. A sandbox token cannot activate an account.',
|
|
9
|
+
ambiguous: 'Activation credentials or target environments conflict. Specify --url, PLAYCRAFT_ENV when needed, and an explicit personal access token using --token or PLAYCRAFT_TOKEN.',
|
|
10
|
+
invalidTarget: 'Use a web service base address (http:// or https://) without credentials, a query, or a fragment.',
|
|
11
|
+
failed: 'Account activation failed. Check the target environment and token before trying again.',
|
|
12
|
+
unavailable: 'Account activation could not be confirmed. Check your account status before explicitly trying again.',
|
|
13
|
+
},
|
|
14
|
+
zh: {
|
|
15
|
+
activateOption: '明确使用当前个人访问令牌激活账号',
|
|
16
|
+
tokenOption: '用于 --activate 的个人访问令牌(也可使用 PLAYCRAFT_TOKEN)',
|
|
17
|
+
pending: '账号需要激活。请使用同一令牌和目标环境主动运行 `playcraft login --activate`,然后重新执行原命令。',
|
|
18
|
+
success: '账号已激活,请重新执行原命令继续。',
|
|
19
|
+
missingToken: '没有与目标环境匹配的个人访问令牌。请提供 PLAYCRAFT_TOKEN,或先登录此环境。',
|
|
20
|
+
invalidToken: '激活账号需要个人访问令牌,沙箱令牌不能用于激活账号。',
|
|
21
|
+
ambiguous: '激活凭据或目标环境存在冲突。请明确指定 --url、所需的 PLAYCRAFT_ENV,并通过 --token 或 PLAYCRAFT_TOKEN 提供个人访问令牌。',
|
|
22
|
+
invalidTarget: '激活目标必须是网页服务基址(http:// 或 https://),不能包含凭据、查询参数或片段。',
|
|
23
|
+
failed: '账号激活失败,请检查目标环境和令牌后再尝试。',
|
|
24
|
+
unavailable: '无法确认账号是否已激活。请先检查账号状态,再主动决定是否重试。',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
export function accountActivationMessage(key) {
|
|
28
|
+
const locale = process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en';
|
|
29
|
+
return messages[locale.startsWith('zh') ? 'zh' : 'en'][key];
|
|
30
|
+
}
|
package/dist/commands/login.js
CHANGED
|
@@ -2,6 +2,7 @@ import http from 'http';
|
|
|
2
2
|
import { exec } from 'child_process';
|
|
3
3
|
import crypto from 'crypto';
|
|
4
4
|
import { saveGlobalConfig, loadGlobalConfig } from '../config.js';
|
|
5
|
+
import { activateAccount } from '../auth/account-activation.js';
|
|
5
6
|
/** 默认云端 Portal(Next.js `/cli-auth` 与 API 同源时的基址;可通过 --url 或 PLAYCRAFT_URL 覆盖) */
|
|
6
7
|
export const DEFAULT_PLAYCRAFT_CLOUD_BASE = 'https://playcraft.aix.intlgame.com';
|
|
7
8
|
const CALLBACK_PORT = 3456;
|
|
@@ -100,6 +101,10 @@ export async function renewExistingToken(options) {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
export async function loginCommand(options) {
|
|
104
|
+
if (options.activate) {
|
|
105
|
+
await activateAccount(options, DEFAULT_PLAYCRAFT_CLOUD_BASE);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
103
108
|
const backendUrl = resolveLoginBaseUrl(options);
|
|
104
109
|
const callbackPort = options.callbackPort ?? CALLBACK_PORT;
|
|
105
110
|
const globalConfig = loadGlobalConfig();
|
|
@@ -14,7 +14,7 @@ export async function runWorkspaceRuntimeServe(configPath) {
|
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
16
|
try {
|
|
17
|
-
const runtime = await startWorkspaceRuntime(config);
|
|
17
|
+
const runtime = await startWorkspaceRuntime(config, { configPath });
|
|
18
18
|
console.log(JSON.stringify({
|
|
19
19
|
status: 'listening',
|
|
20
20
|
url: runtime.url,
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { accountActivationMessage } from './auth/messages.js';
|
|
3
4
|
import { readFileSync } from 'fs';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
import { dirname, join } from 'path';
|
|
@@ -66,6 +67,8 @@ program
|
|
|
66
67
|
.command('login')
|
|
67
68
|
.description('登录 PlayCraft 账号并获取 CLI token')
|
|
68
69
|
.option('--url <url>', 'Portal / API 基址(默认 https://playcraft.aix.intlgame.com,可用环境变量 PLAYCRAFT_URL 覆盖)')
|
|
70
|
+
.option('--activate', accountActivationMessage('activateOption'))
|
|
71
|
+
.option('--token <token>', accountActivationMessage('tokenOption'))
|
|
69
72
|
.action(async (options) => {
|
|
70
73
|
const { loginCommand } = await import('./commands/login.js');
|
|
71
74
|
await loginCommand(options);
|
|
@@ -5,6 +5,7 @@ import { loadGlobalConfig } from '../config.js';
|
|
|
5
5
|
import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
|
|
6
6
|
import { AGENT_TOOL_OPERATION_PROTOCOL, AGENT_TOOL_OPERATION_HEADERS, AGENT_TOOL_OPERATION_CAPABILITIES_PATH, } from '@playcraft/common/agent-tools';
|
|
7
7
|
import { beginToolOperation } from './tool-operation-journal.js';
|
|
8
|
+
import { AccountActivationError, parseErrorPayload, pendingAccountActivationError, } from '../auth/account-activation.js';
|
|
8
9
|
/**
|
|
9
10
|
* 判断 token 是否为用户 PAT(Personal Access Token)。
|
|
10
11
|
* PAT 以 "playcraft_pat_" 开头;沙箱 JWT 为三段式 base64。
|
|
@@ -86,6 +87,14 @@ export class AgentApiClient {
|
|
|
86
87
|
signal: AbortSignal.timeout(30_000),
|
|
87
88
|
});
|
|
88
89
|
if (!capabilityResponse.ok) {
|
|
90
|
+
if (capabilityResponse.status === 403) {
|
|
91
|
+
const pending = pendingAccountActivationError(403, parseErrorPayload(await capabilityResponse.clone().text().catch(() => '')));
|
|
92
|
+
if (pending) {
|
|
93
|
+
if (operation.createdNew && !operation.settledOnly)
|
|
94
|
+
operation.reject();
|
|
95
|
+
throw pending;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
89
98
|
await capabilityResponse.body?.cancel();
|
|
90
99
|
throw new ToolRequestError(`Business tool protocol unavailable (${capabilityResponse.status}); no operation was sent`);
|
|
91
100
|
}
|
|
@@ -108,6 +117,14 @@ export class AgentApiClient {
|
|
|
108
117
|
});
|
|
109
118
|
if (res.headers.get(AGENT_TOOL_OPERATION_HEADERS.protocol) !== AGENT_TOOL_OPERATION_PROTOCOL ||
|
|
110
119
|
res.headers.get(AGENT_TOOL_OPERATION_HEADERS.requestId) !== operation.requestId) {
|
|
120
|
+
if (res.status === 403) {
|
|
121
|
+
const pending = pendingAccountActivationError(403, parseErrorPayload(await res.clone().text().catch(() => '')));
|
|
122
|
+
if (pending) {
|
|
123
|
+
if (operation.createdNew && !operation.settledOnly)
|
|
124
|
+
operation.reject();
|
|
125
|
+
throw pending;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
111
128
|
await res.body?.cancel();
|
|
112
129
|
// Possibly an instance that ignores identities: a rerun may only read the receipt.
|
|
113
130
|
operation.requireReceipt();
|
|
@@ -115,6 +132,9 @@ export class AgentApiClient {
|
|
|
115
132
|
}
|
|
116
133
|
if (!res.ok) {
|
|
117
134
|
const text = await res.text().catch(() => res.statusText);
|
|
135
|
+
const pending = pendingAccountActivationError(res.status, parseErrorPayload(text));
|
|
136
|
+
if (pending)
|
|
137
|
+
throw pending;
|
|
118
138
|
const code = errorCode(text);
|
|
119
139
|
if (res.status === 409 && code === 'business_operation_in_progress')
|
|
120
140
|
throw new ToolRequestError(`API 409: ${text}\nTool request ${operation.requestId} is still running. Rerun the ` +
|
|
@@ -141,7 +161,7 @@ export class AgentApiClient {
|
|
|
141
161
|
return result;
|
|
142
162
|
}
|
|
143
163
|
catch (error) {
|
|
144
|
-
if (error instanceof ToolRequestError)
|
|
164
|
+
if (error instanceof ToolRequestError || error instanceof AccountActivationError)
|
|
145
165
|
throw error;
|
|
146
166
|
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${operation.settledOnly
|
|
147
167
|
? `Tool request ${operation.requestId}; record: ${operation.file}. Its settled result is not available yet.`
|
|
@@ -164,6 +184,9 @@ export class AgentApiClient {
|
|
|
164
184
|
});
|
|
165
185
|
if (!res.ok) {
|
|
166
186
|
const text = await res.text().catch(() => res.statusText);
|
|
187
|
+
const pending = pendingAccountActivationError(res.status, parseErrorPayload(text));
|
|
188
|
+
if (pending)
|
|
189
|
+
throw pending;
|
|
167
190
|
throw new Error(`API ${res.status}: ${text}`);
|
|
168
191
|
}
|
|
169
192
|
return res.json();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { loadGlobalConfig } from '../config.js';
|
|
3
3
|
import { DEFAULT_PLAYCRAFT_CLOUD_BASE } from '../commands/login.js';
|
|
4
|
+
import { pendingAccountActivationError } from '../auth/account-activation.js';
|
|
4
5
|
export class PlayCraftApiError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
status;
|
|
@@ -95,19 +96,20 @@ export class PlayCraftHttpClient {
|
|
|
95
96
|
}
|
|
96
97
|
if (!response.ok) {
|
|
97
98
|
const error = payload?.error;
|
|
98
|
-
const
|
|
99
|
-
const
|
|
99
|
+
const pending = pendingAccountActivationError(response.status, payload);
|
|
100
|
+
const code = pending?.code ?? (typeof error?.code === 'string' ? error.code : statusCode(response.status));
|
|
101
|
+
const message = pending?.message ?? (typeof error?.message === 'string'
|
|
100
102
|
? error.message
|
|
101
103
|
: typeof payload?.message === 'string'
|
|
102
104
|
? payload.message
|
|
103
105
|
: typeof payload === 'string'
|
|
104
106
|
? payload
|
|
105
|
-
: response.statusText || 'PlayCraft request failed';
|
|
107
|
+
: response.statusText || 'PlayCraft request failed');
|
|
106
108
|
throw new PlayCraftApiError({
|
|
107
109
|
code,
|
|
108
110
|
message,
|
|
109
111
|
status: response.status,
|
|
110
|
-
details: error?.details ?? payload,
|
|
112
|
+
details: pending ? { loginCommand: 'playcraft login --activate' } : error?.details ?? payload,
|
|
111
113
|
retryable: response.status >= 500,
|
|
112
114
|
});
|
|
113
115
|
}
|
|
@@ -56,8 +56,10 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
|
|
|
56
56
|
createdAt: now,
|
|
57
57
|
updatedAt: now,
|
|
58
58
|
};
|
|
59
|
+
let createdNew = false;
|
|
59
60
|
try {
|
|
60
61
|
writeFileSync(file, JSON.stringify(entry), { flag: 'wx', mode: 0o600 });
|
|
62
|
+
createdNew = true;
|
|
61
63
|
}
|
|
62
64
|
catch (error) {
|
|
63
65
|
if (error.code !== 'EEXIST')
|
|
@@ -81,6 +83,8 @@ export function beginToolOperation(path, serializedBody, recoverId, options = {}
|
|
|
81
83
|
update({ holder });
|
|
82
84
|
return {
|
|
83
85
|
requestId,
|
|
86
|
+
/** This call created the identity; a refusal cannot settle an older unknown operation. */
|
|
87
|
+
createdNew,
|
|
84
88
|
/** An explicit recovery, or a server that did not acknowledge the identity, reads only. */
|
|
85
89
|
settledOnly: recoverId !== undefined || unconfirmed?.receiptOnly === true,
|
|
86
90
|
file,
|
|
@@ -7,6 +7,7 @@ import { writePlaycraftCodexConfig } from './config-toml.js';
|
|
|
7
7
|
import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
|
|
8
8
|
import { projectNativeThread } from '@playcraft/common/cloud-workspace/runtime';
|
|
9
9
|
import { collectedEvent, NativePreparationError } from './native-adapter.js';
|
|
10
|
+
import { exportTranscript } from './transcripts.js';
|
|
10
11
|
function asRecord(value) {
|
|
11
12
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
12
13
|
? value
|
|
@@ -68,6 +69,18 @@ export function createCodexAppServerAdapter(options) {
|
|
|
68
69
|
for (const listener of collected)
|
|
69
70
|
listener(event);
|
|
70
71
|
}
|
|
72
|
+
function transcriptHome() {
|
|
73
|
+
if (!launcherSocket)
|
|
74
|
+
return { homeDir };
|
|
75
|
+
return {
|
|
76
|
+
homeDir,
|
|
77
|
+
launcher: {
|
|
78
|
+
socketPath: launcherSocket,
|
|
79
|
+
runtimeEpoch: executionEpoch,
|
|
80
|
+
cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
71
84
|
async function ensureStarted() {
|
|
72
85
|
if (readyFlag && rpc)
|
|
73
86
|
return;
|
|
@@ -272,6 +285,9 @@ export function createCodexAppServerAdapter(options) {
|
|
|
272
285
|
return undefined;
|
|
273
286
|
}
|
|
274
287
|
},
|
|
288
|
+
exportThread(input) {
|
|
289
|
+
return exportTranscript(transcriptHome(), input.threadId);
|
|
290
|
+
},
|
|
275
291
|
async captureNativeCheckpoint(input) {
|
|
276
292
|
const thread = threads.get(input.threadId);
|
|
277
293
|
if (!thread || thread.sessionId !== input.sessionId)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { startLauncherProcess } from './launcher-client.js';
|
|
5
|
+
export const TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
|
|
6
|
+
const THREAD_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
7
|
+
const TRANSCRIPT_PATH = /^sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-[A-Za-z0-9_.-]{1,200}\.jsonl$/;
|
|
8
|
+
const COMMAND_TIMEOUT_MS = 60_000;
|
|
9
|
+
/**
|
|
10
|
+
* Reads the transcript Codex keeps for one thread. Codex appends to it while a turn runs, so a
|
|
11
|
+
* trailing partial line is dropped; what is returned always parses line by line.
|
|
12
|
+
*/
|
|
13
|
+
export async function exportTranscript(home, threadId) {
|
|
14
|
+
if (!THREAD_ID.test(threadId))
|
|
15
|
+
return undefined;
|
|
16
|
+
const found = home.launcher
|
|
17
|
+
? await exportAsDeveloper(home.homeDir, home.launcher, threadId)
|
|
18
|
+
: await exportDirect(home.homeDir, threadId);
|
|
19
|
+
if (!found || !transcriptPathFor(found.path, threadId))
|
|
20
|
+
return undefined;
|
|
21
|
+
const end = found.body.lastIndexOf(0x0a);
|
|
22
|
+
if (end < 0)
|
|
23
|
+
return undefined;
|
|
24
|
+
return { path: found.path, body: found.body.subarray(0, end + 1) };
|
|
25
|
+
}
|
|
26
|
+
function transcriptPathFor(path, threadId) {
|
|
27
|
+
return TRANSCRIPT_PATH.test(path) && path.endsWith(`-${threadId}.jsonl`);
|
|
28
|
+
}
|
|
29
|
+
async function exportDirect(homeDir, threadId) {
|
|
30
|
+
const suffix = `-${threadId}.jsonl`;
|
|
31
|
+
for (const year of await names(join(homeDir, 'sessions'))) {
|
|
32
|
+
for (const month of await names(join(homeDir, 'sessions', year))) {
|
|
33
|
+
for (const day of await names(join(homeDir, 'sessions', year, month))) {
|
|
34
|
+
const directory = join('sessions', year, month, day);
|
|
35
|
+
const match = (await names(join(homeDir, directory))).find((name) => name.endsWith(suffix));
|
|
36
|
+
if (!match)
|
|
37
|
+
continue;
|
|
38
|
+
const path = `${directory}/${match}`;
|
|
39
|
+
const absolute = join(homeDir, path);
|
|
40
|
+
if ((await stat(absolute)).size > TRANSCRIPT_MAX_BYTES)
|
|
41
|
+
return undefined;
|
|
42
|
+
return { path, body: await readFile(absolute) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
async function exportAsDeveloper(homeDir, launcher, threadId) {
|
|
49
|
+
const limitKib = TRANSCRIPT_MAX_BYTES / 1024;
|
|
50
|
+
const outcome = await runAsDeveloper(launcher, [
|
|
51
|
+
`home=${quote(homeDir)}`,
|
|
52
|
+
`f=$(find "$home/sessions" -mindepth 4 -maxdepth 4 -type f -name ${quote(`rollout-*-${threadId}.jsonl`)} -size -${limitKib}k 2>/dev/null | head -n 1)`,
|
|
53
|
+
'[ -n "$f" ] || exit 3',
|
|
54
|
+
'printf \'%s\\n\' "${f#"$home"/}"',
|
|
55
|
+
'base64 -w 0 "$f"',
|
|
56
|
+
].join('; '));
|
|
57
|
+
if (outcome.code !== 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const text = outcome.stdout.toString('utf8');
|
|
60
|
+
const newline = text.indexOf('\n');
|
|
61
|
+
if (newline < 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
return { path: text.slice(0, newline), body: Buffer.from(text.slice(newline + 1).trim(), 'base64') };
|
|
64
|
+
}
|
|
65
|
+
async function runAsDeveloper(launcher, command) {
|
|
66
|
+
const child = await startLauncherProcess({
|
|
67
|
+
socketPath: launcher.socketPath,
|
|
68
|
+
processId: `transcript-${randomUUID()}`,
|
|
69
|
+
runtimeEpoch: launcher.runtimeEpoch,
|
|
70
|
+
argv: ['/usr/bin/bash', '-c', `{ ${command}; } </dev/null 2>/dev/null`],
|
|
71
|
+
cwd: launcher.cwd,
|
|
72
|
+
env: {},
|
|
73
|
+
});
|
|
74
|
+
child.stdin?.end();
|
|
75
|
+
const chunks = [];
|
|
76
|
+
child.stdout?.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
77
|
+
const code = await new Promise((resolve) => {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
void child.stop().catch(() => undefined);
|
|
80
|
+
resolve(null);
|
|
81
|
+
}, COMMAND_TIMEOUT_MS);
|
|
82
|
+
child.onExit((value) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
resolve(value);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
return { code, stdout: Buffer.concat(chunks) };
|
|
88
|
+
}
|
|
89
|
+
async function names(directory) {
|
|
90
|
+
return readdir(directory).then((entries) => entries.sort(), () => []);
|
|
91
|
+
}
|
|
92
|
+
function quote(value) {
|
|
93
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
94
|
+
}
|
|
@@ -33,6 +33,12 @@ export async function previewWorkspaceRoot(state, operation) {
|
|
|
33
33
|
if (!operation.workspaceId || !/^[A-Za-z0-9_-]+$/.test(operation.operationId)) {
|
|
34
34
|
throw new PreviewFailure('invalid_preview_publish');
|
|
35
35
|
}
|
|
36
|
+
if (operation.sessionId !== undefined) {
|
|
37
|
+
const session = state.journal.getSession(operation.sessionId);
|
|
38
|
+
if (!session || (state.config.workspaceMode === 'multi' && session.workspaceId !== operation.workspaceId)) {
|
|
39
|
+
throw new PreviewFailure('session_workspace_mismatch');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
36
42
|
return workspaceRoot(state, operation.workspaceId);
|
|
37
43
|
}
|
|
38
44
|
/** Tracks a background publish so tests and shutdown can wait for it. */
|
|
@@ -55,10 +61,13 @@ export async function recoverInterruptedPreviews(journal) {
|
|
|
55
61
|
}
|
|
56
62
|
export async function runPreviewPublish(state, operation, root) {
|
|
57
63
|
const workspaceId = operation.workspaceId;
|
|
64
|
+
const scope = operation.sessionId === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { sessionId: operation.sessionId, clientRequestId: operation.clientRequestId };
|
|
58
67
|
const outputDir = join(root, OUTPUT_FOLDER, operation.operationId);
|
|
59
68
|
const output = { log: '' };
|
|
60
69
|
try {
|
|
61
|
-
await ensureDependencies(state, workspaceId, root);
|
|
70
|
+
await ensureDependencies(state, workspaceId, root, output);
|
|
62
71
|
await excludeFromGit(root, `/${OUTPUT_FOLDER}/`);
|
|
63
72
|
await excludeFromGit(root, `/${BUILDER_TEMP_FOLDER}/`);
|
|
64
73
|
await buildPreview(state, root, outputDir, operation.operationId, output);
|
|
@@ -73,6 +82,7 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
73
82
|
const source = await sourceRevision(root);
|
|
74
83
|
const reserved = previewReservationSchema.parse(await postControl(state, '/runtime/internal/preview-reservations', {
|
|
75
84
|
workspaceId,
|
|
85
|
+
...scope,
|
|
76
86
|
operationId: operation.operationId,
|
|
77
87
|
contentDigest,
|
|
78
88
|
contentMd5,
|
|
@@ -81,6 +91,8 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
81
91
|
dirty: source.dirty,
|
|
82
92
|
cliVersion: state.config.cliVersion,
|
|
83
93
|
}));
|
|
94
|
+
if (reserved.sessionId !== operation.sessionId)
|
|
95
|
+
throw new PreviewFailure('preview_scope_mismatch');
|
|
84
96
|
if (!reserved.alreadyUploaded) {
|
|
85
97
|
const uploaded = await fetch(reserved.uploadUrl, {
|
|
86
98
|
method: 'PUT',
|
|
@@ -99,11 +111,16 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
99
111
|
}
|
|
100
112
|
const published = previewPublishResultSchema.parse(await postControl(state, '/runtime/internal/preview-completions', {
|
|
101
113
|
workspaceId,
|
|
114
|
+
...scope,
|
|
102
115
|
operationId: operation.operationId,
|
|
103
116
|
artifactId: reserved.artifactId,
|
|
104
117
|
expectedPublishRevision: reserved.expectedPublishRevision,
|
|
105
118
|
logTail: tail(output.log),
|
|
106
119
|
}));
|
|
120
|
+
if (published.sessionId !== operation.sessionId || published.previewId !== reserved.previewId ||
|
|
121
|
+
published.artifactId !== reserved.artifactId) {
|
|
122
|
+
throw new PreviewFailure('preview_scope_mismatch');
|
|
123
|
+
}
|
|
107
124
|
if (published.status === 'conflict')
|
|
108
125
|
throw new PreviewFailure('publish_conflict');
|
|
109
126
|
return published;
|
|
@@ -113,6 +130,7 @@ export async function runPreviewPublish(state, operation, root) {
|
|
|
113
130
|
if (code !== 'publish_conflict') {
|
|
114
131
|
await postControl(state, '/runtime/internal/preview-failures', {
|
|
115
132
|
workspaceId,
|
|
133
|
+
...scope,
|
|
116
134
|
operationId: operation.operationId,
|
|
117
135
|
error: code,
|
|
118
136
|
logTail: tail(output.log || (error instanceof Error ? error.message : '')),
|
|
@@ -137,11 +155,13 @@ export function previewFailureCode(error) {
|
|
|
137
155
|
* Codex added since the workspace opened is installed first. A running install is joined rather
|
|
138
156
|
* than started twice; a failed one is retried once here.
|
|
139
157
|
*/
|
|
140
|
-
async function ensureDependencies(state, workspaceId, root) {
|
|
158
|
+
async function ensureDependencies(state, workspaceId, root, output) {
|
|
141
159
|
await scheduleDependencyInstall(state, workspaceId, root);
|
|
142
|
-
const
|
|
143
|
-
if (status !== 'ready' && status !== 'none')
|
|
160
|
+
const dependencies = readDependencies(state, workspaceId);
|
|
161
|
+
if (dependencies.status !== 'ready' && dependencies.status !== 'none') {
|
|
162
|
+
output.log = [dependencies.error, dependencies.logTail].filter(Boolean).join('\n');
|
|
144
163
|
throw new PreviewFailure('dependencies_not_ready');
|
|
164
|
+
}
|
|
145
165
|
}
|
|
146
166
|
async function buildPreview(state, root, outputDir, operationId, output) {
|
|
147
167
|
// Tests replace the build; the real one needs a project and the published CLI.
|
|
@@ -33,11 +33,15 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
33
33
|
try {
|
|
34
34
|
journal = new RuntimeJournal(config.stateDir);
|
|
35
35
|
await journal.load();
|
|
36
|
+
const previousEpoch = journal.storedRuntimeEpoch();
|
|
37
|
+
const previousGroup = journal.executionGroup();
|
|
38
|
+
const previousIdentityMatches = journal.epochsSnapshot().some((epoch) => epoch.runtime_id === config.runtimeId && epoch.generation === config.generation && epoch.runtime_epoch === previousEpoch);
|
|
36
39
|
await recoverInterruptedInstalls(journal);
|
|
37
40
|
await recoverInterruptedPreviews(journal);
|
|
38
41
|
await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
|
|
39
42
|
const state = {
|
|
40
43
|
config: { ...config, protocolMinor: negotiated.protocolMinor },
|
|
44
|
+
configPath: options.configPath,
|
|
41
45
|
journal,
|
|
42
46
|
acceptedLeaseFence: journal.acceptedLeaseFence(),
|
|
43
47
|
gitAvailable: await probeGitBinary(),
|
|
@@ -63,8 +67,28 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
63
67
|
try {
|
|
64
68
|
await native?.start?.();
|
|
65
69
|
const group = native?.executionProcess?.();
|
|
66
|
-
if (group)
|
|
67
|
-
|
|
70
|
+
if (group) {
|
|
71
|
+
let previousStopped = false;
|
|
72
|
+
if (previousGroup.pid && previousGroup.pid !== group.pid) {
|
|
73
|
+
try {
|
|
74
|
+
process.kill(previousGroup.pid, 0);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
previousStopped = error.code === 'ESRCH';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// The supervisor advances the trusted config epoch only after stopping the old
|
|
81
|
+
// native group. Reconfirm its recorded PID before publishing that replacement.
|
|
82
|
+
if (previousIdentityMatches && previousEpoch !== undefined && config.runtimeEpoch > previousEpoch && previousStopped) {
|
|
83
|
+
await journal.writeExecutionEpoch({
|
|
84
|
+
runtimeEpoch: state.runtimeEpoch, ...group, previousPid: previousGroup.pid, stopConfirmed: true,
|
|
85
|
+
});
|
|
86
|
+
await journal.replaceSessions(journal.listSessions().map((session) => ({ ...session, recoverability: 'history_only' })));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
await journal.noteExecutionGroup(group);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
68
92
|
}
|
|
69
93
|
catch (error) {
|
|
70
94
|
const code = error instanceof Error ? error.message : '';
|