@playcraft/cli 0.1.6 → 0.1.8

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.
@@ -2,6 +2,11 @@ import { connect as connectTcp } from 'node:net';
2
2
  import { WebSocket, WebSocketServer } from 'ws';
3
3
  import { desktopHealthSchema, desktopResourceName, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
4
4
  import { controlTokenMatches, extractControlToken } from './auth.js';
5
+ /**
6
+ * A takeover holds the desktop for this long, whether or not a page is watching, and ends on its
7
+ * own unless the user renews it.
8
+ */
9
+ const HOLD_MS = 20 * 60_000;
5
10
  const sources = new WeakMap();
6
11
  const hosts = new WeakMap();
7
12
  export function setDesktopSource(state, source) {
@@ -33,6 +38,9 @@ export class DesktopHost {
33
38
  sockets = new WebSocketServer({ noServer: true });
34
39
  source;
35
40
  upstream = loopbackUpstream(process.env.PLAYCRAFT_DESKTOP_UPSTREAM);
41
+ holdMs = holdDuration(process.env.PLAYCRAFT_DESKTOP_HOLD_MS);
42
+ holdExpiresAt;
43
+ holdTimer;
36
44
  constructor(state) {
37
45
  this.state = state;
38
46
  this.source = sources.get(state);
@@ -46,6 +54,9 @@ export class DesktopHost {
46
54
  streamGeneration: this.generation,
47
55
  holder: this.holder,
48
56
  agentInput: this.holder === 'human' ? 'revoked' : 'open',
57
+ ...(this.holder === 'human' && this.holdExpiresAt
58
+ ? { holdExpiresAt: new Date(this.holdExpiresAt).toISOString() }
59
+ : {}),
49
60
  });
50
61
  }
51
62
  async refresh() {
@@ -71,12 +82,21 @@ export class DesktopHost {
71
82
  }
72
83
  this.holder = 'human';
73
84
  this.cutViewers();
85
+ this.hold();
86
+ writeJson(response, 200, this.snapshot());
87
+ return;
88
+ }
89
+ if (request.method === 'POST' && url.pathname === '/runtime/desktop/renew') {
90
+ if (this.holder !== 'human') {
91
+ writeError(response, 'desktop_not_held');
92
+ return;
93
+ }
94
+ this.hold();
74
95
  writeJson(response, 200, this.snapshot());
75
96
  return;
76
97
  }
77
98
  if (request.method === 'DELETE' && url.pathname === '/runtime/desktop') {
78
- this.holder = 'none';
79
- this.cutViewers();
99
+ this.release();
80
100
  writeJson(response, 200, this.snapshot());
81
101
  return;
82
102
  }
@@ -114,9 +134,30 @@ export class DesktopHost {
114
134
  this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(ws, generation));
115
135
  }
116
136
  dispose() {
137
+ this.release();
138
+ this.sockets.close();
139
+ }
140
+ release() {
141
+ this.clearHold();
117
142
  this.holder = 'none';
118
143
  this.cutViewers();
119
- this.sockets.close();
144
+ }
145
+ /** Starts or renews the hold; when it runs out the desktop is released as if by hand. */
146
+ hold() {
147
+ this.clearHold();
148
+ this.holdExpiresAt = Date.now() + this.holdMs;
149
+ this.holdTimer = setTimeout(() => {
150
+ this.holdTimer = undefined;
151
+ if (this.holder === 'human')
152
+ this.release();
153
+ }, this.holdMs);
154
+ this.holdTimer.unref?.();
155
+ }
156
+ clearHold() {
157
+ if (this.holdTimer)
158
+ clearTimeout(this.holdTimer);
159
+ this.holdTimer = undefined;
160
+ this.holdExpiresAt = undefined;
120
161
  }
121
162
  currentStatus() {
122
163
  if (this.source)
@@ -248,6 +289,7 @@ export function desktopRoute(pathname) {
248
289
  return (pathname === '/runtime/desktop/health' ||
249
290
  pathname === '/runtime/desktop/connect' ||
250
291
  pathname === '/runtime/desktop/takeover' ||
292
+ pathname === '/runtime/desktop/renew' ||
251
293
  pathname === '/runtime/desktop' ||
252
294
  pathname.startsWith('/runtime/desktop/resources/'));
253
295
  }
@@ -268,6 +310,10 @@ function loopbackUpstream(value) {
268
310
  return undefined;
269
311
  return { port };
270
312
  }
313
+ function holdDuration(value) {
314
+ const parsed = Number(value);
315
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : HOLD_MS;
316
+ }
271
317
  function probePort(port) {
272
318
  return new Promise((resolve) => {
273
319
  const socket = connectTcp({ host: '127.0.0.1', port });
@@ -596,7 +596,7 @@ async function executeGitCommit(state, scopeKey, operation, payload) {
596
596
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_commit' }));
597
597
  }
598
598
  try {
599
- const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId), parsed.data);
599
+ const sha = await commitWorkspaceGit(await workspaceRoot(state, operation.workspaceId), parsed.data, boundBranch(state, operation));
600
600
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
601
601
  await recordNativeEvents(state, accepted, [
602
602
  {
@@ -617,7 +617,7 @@ async function executeGitPush(state, scopeKey, operation, payload) {
617
617
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'remote_not_configurable' }));
618
618
  }
619
619
  try {
620
- await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId));
620
+ await pushWorkspaceGit(await workspaceRoot(state, operation.workspaceId), boundBranch(state, operation));
621
621
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
622
622
  await recordNativeEvents(state, accepted, [
623
623
  {
@@ -765,6 +765,10 @@ async function executeTerminalCreate(state, scopeKey, operation, payload) {
765
765
  ]);
766
766
  return persist(state, scopeKey, transitionOperation(accepted, 'completed', { terminalId, cwd: record.cwd }));
767
767
  }
768
+ /** The branch a registered workspace is bound to; the single-workspace mode has none. */
769
+ function boundBranch(state, operation) {
770
+ return operation.workspaceId ? state.journal.getWorkspace(operation.workspaceId)?.branchName : undefined;
771
+ }
768
772
  function gitErrorCode(error) {
769
773
  return error instanceof GitWorkspaceError ? error.code : 'git_command_failed';
770
774
  }
@@ -171,8 +171,34 @@ function splitLogLine(line) {
171
171
  return [line.slice(0, first), line.slice(first + 1), ''];
172
172
  return [line.slice(0, first), line.slice(first + 1, second), line.slice(second + 1)];
173
173
  }
174
- export async function commitWorkspaceGit(root, payload) {
174
+ /**
175
+ * Commit and push stay on the branch the workspace is bound to. A switch made in a shell or by an
176
+ * agent is reported, never followed, so work cannot land on another Remix.
177
+ */
178
+ async function assertBoundBranch(rootReal, boundBranch) {
179
+ if (!boundBranch)
180
+ return;
181
+ const current = await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
182
+ const branch = current.code === 0 ? current.stdout.trim() : '';
183
+ if (branch !== boundBranch) {
184
+ throw new GitWorkspaceError('workspace_branch_mismatch', branch || 'detached HEAD');
185
+ }
186
+ }
187
+ /**
188
+ * A push the remote refused for this ref is a definite outcome, unlike a lost response. Porcelain
189
+ * output marks the ref with "!" and says whether the remote has commits this checkout lacks.
190
+ */
191
+ function pushRejection(stdout) {
192
+ const line = stdout.split('\n').find((item) => item.startsWith('!\t'));
193
+ if (!line)
194
+ return undefined;
195
+ if (/\[rejected\] \((?:non-fast-forward|fetch first)\)/.test(line))
196
+ return 'git_push_remote_ahead';
197
+ return 'git_push_rejected';
198
+ }
199
+ export async function commitWorkspaceGit(root, payload, boundBranch) {
175
200
  const rootReal = await ensureWorkspaceGit(root);
201
+ await assertBoundBranch(rootReal, boundBranch);
176
202
  if (payload.stagedOnly) {
177
203
  // The index is the commit; nothing is added.
178
204
  }
@@ -190,8 +216,9 @@ export async function commitWorkspaceGit(root, payload) {
190
216
  await git(rootReal, ['commit', '-m', payload.message], 20000);
191
217
  return (await git(rootReal, ['rev-parse', 'HEAD'])).trim();
192
218
  }
193
- export async function pushWorkspaceGit(root) {
219
+ export async function pushWorkspaceGit(root, boundBranch) {
194
220
  const rootReal = await ensureWorkspaceGit(root);
221
+ await assertBoundBranch(rootReal, boundBranch);
195
222
  const origin = await gitAllowFail(rootReal, ['remote', 'get-url', 'origin']);
196
223
  if (origin.code !== 0) {
197
224
  throw new GitWorkspaceError('remote_not_configured', 'origin');
@@ -200,12 +227,16 @@ export async function pushWorkspaceGit(root) {
200
227
  if (!ref.startsWith('refs/heads/'))
201
228
  throw new GitWorkspaceError('git_command_failed', 'git_branch_required');
202
229
  const commit = (await git(rootReal, ['rev-parse', '--verify', 'HEAD^{commit}'])).trim();
230
+ let pushed;
203
231
  try {
204
- const pushed = await gitAllowFail(rootReal, ['push', '--porcelain', 'origin', `${commit}:${ref}`], 90000);
205
- if (pushed.code === 0)
206
- return;
232
+ pushed = await gitAllowFail(rootReal, ['push', '--porcelain', 'origin', `${commit}:${ref}`], 90000);
207
233
  }
208
234
  catch { /* A lost response does not prove the remote rejected the update. */ }
235
+ if (pushed?.code === 0)
236
+ return;
237
+ const rejected = pushed ? pushRejection(pushed.stdout) : undefined;
238
+ if (rejected)
239
+ throw new GitWorkspaceError(rejected, rejected);
209
240
  try {
210
241
  const remote = await gitAllowFail(rootReal, ['ls-remote', '--refs', 'origin', ref], 20000);
211
242
  if (remote.code === 0 && remote.stdout.trim() === `${commit}\t${ref}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,8 +48,8 @@
48
48
  "@gltf-transform/core": "^4.3.0",
49
49
  "@gltf-transform/extensions": "^4.3.0",
50
50
  "@gltf-transform/functions": "^4.3.0",
51
- "@playcraft/build": "^0.1.6",
52
- "@playcraft/common": "^0.1.6",
51
+ "@playcraft/build": "^0.1.8",
52
+ "@playcraft/common": "^0.1.8",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",